nx_visual_novel.nx source
↩ module page · 300 lines · 12695 B
1// nx_visual_novel.nx -- VN0+VN1: the Nishi visual-novel ENGINE, bits-up
2// (operator 2026-06-10: "start with visual novels as an easy bottom up lift
3// to beat py visual novel and other engines" on the road to AAA-in-browser).
4//
5// What a VN engine is: a deterministic script machine (say / choice /
6// variables / branches / endings) + a renderer. This module ships:
7// * the script op machine (SAY, CHOICE, SET, IFGOTO, JUMP, END),
8// * deterministic state (vars, state-hash, step count) -- replayable,
9// * the fd-99 HTML renderer for the nishi-host mount() pattern
10// (same browser delivery as /tictactoe: render emits scene HTML,
11// data-action clicks dispatch back -- zero per-app JS),
12// * a complete family demo story ("The Family Garden": 2 choice points,
13// 3 endings, a tended-count variable) proving every op end-to-end.
14//
15// BEAT-Ren'Py axes (G3 scorecard, measured not asserted):
16// bundle: Ren'Py ships a ~50MB Python runtime, web export ~30MB emscripten;
17// this engine compiles to a WASM measured in TENS OF KILOBYTES.
18// load: instant (one small wasm fetch). determinism: state-hash replay.
19// sovereignty: no Python, no engine runtime, user owns every bit.
20// Honest scope: VN0/1 = engine+browser text/choices. Sprites/backgrounds/
21// audio = VN3; authoring TEXT format (beat Ren'Py script) = VN4 (composes
22// the e-reader/format arc); scorecard vs Ren'Py = VN5.
23//
24// license_tier: ORIGINAL
25import "nx_syscalls.nx"
26const VN_MAGIC_1469598103934665603: i64 = 1469598103934665603
27const VN_MAGIC_1099511628211: i64 = 1099511628211
28
29// ===== script ops (stride 8 i64) ====================================
30// [0]=kind [1..7] = per-kind operands
31const VN_OP_SAY: i64 = 0 // [1]=speaker_id [2]=text_ptr -> next = pc+1
32const VN_OP_CHOICE: i64 = 1 // [1]=n (2..3) [2]=t0_ptr [3]=tgt0 [4]=t1_ptr [5]=tgt1 [6]=t2_ptr [7]=tgt2
33const VN_OP_SET: i64 = 2 // [1]=var_idx [2]=value (silent)
34const VN_OP_ADD: i64 = 3 // [1]=var_idx [2]=delta (silent)
35const VN_OP_IFGOTO: i64 = 4 // [1]=var_idx [2]=min_val [3]=target (silent: jump if var >= min)
36const VN_OP_JUMP: i64 = 5 // [1]=target (silent)
37const VN_OP_END: i64 = 6 // [1]=epilogue_ptr
38
39const VN_OP_STRIDE: i64 = 8
40
41// ===== state block (*i64) ===========================================
42const VN_S_SCRIPT: i64 = 0 // *i64 ops base
43const VN_S_NOPS: i64 = 1
44const VN_S_PC: i64 = 2
45const VN_S_STEPS: i64 = 3
46const VN_S_ENDED: i64 = 4
47const VN_S_VARS: i64 = 8 // 8 vars: slots 8..15
48const VN_N_VARS: i64 = 8
49const VN_S_STRIDE: i64 = 16
50
51// ===== speakers =====================================================
52const VN_SPK_NARRATOR: i64 = 0
53const VN_SPK_MOM: i64 = 1
54const VN_SPK_KID: i64 = 2
55
56func nx_vn_speaker_name(id: i64) -> *u8 {
57 if id == VN_SPK_MOM { return "Mom" as *u8 }
58 if id == VN_SPK_KID { return "Kid" as *u8 }
59 return "" as *u8 // narrator: no name tag
60}
61
62// ===== script builder helpers =======================================
63func vn_emit_say(ops: *i64, pc: i64, speaker: i64, text: *u8) -> i64 {
64 let o: i64 = pc * VN_OP_STRIDE
65 ops[o] = VN_OP_SAY
66 ops[o+1] = speaker
67 ops[o+2] = text as i64
68 return pc + 1
69}
70func vn_emit_choice2(ops: *i64, pc: i64, t0: *u8, tgt0: i64, t1: *u8, tgt1: i64) -> i64 {
71 let o: i64 = pc * VN_OP_STRIDE
72 ops[o] = VN_OP_CHOICE
73 ops[o+1] = 2
74 ops[o+2] = t0 as i64
75 ops[o+3] = tgt0
76 ops[o+4] = t1 as i64
77 ops[o+5] = tgt1
78 return pc + 1
79}
80func vn_emit_add(ops: *i64, pc: i64, v: i64, delta: i64) -> i64 {
81 let o: i64 = pc * VN_OP_STRIDE
82 ops[o] = VN_OP_ADD
83 ops[o+1] = v
84 ops[o+2] = delta
85 return pc + 1
86}
87func vn_emit_ifgoto(ops: *i64, pc: i64, v: i64, min: i64, target: i64) -> i64 {
88 let o: i64 = pc * VN_OP_STRIDE
89 ops[o] = VN_OP_IFGOTO
90 ops[o+1] = v
91 ops[o+2] = min
92 ops[o+3] = target
93 return pc + 1
94}
95func vn_emit_jump(ops: *i64, pc: i64, target: i64) -> i64 {
96 let o: i64 = pc * VN_OP_STRIDE
97 ops[o] = VN_OP_JUMP
98 ops[o+1] = target
99 return pc + 1
100}
101func vn_emit_end(ops: *i64, pc: i64, epilogue: *u8) -> i64 {
102 let o: i64 = pc * VN_OP_STRIDE
103 ops[o] = VN_OP_END
104 ops[o+1] = epilogue as i64
105 return pc + 1
106}
107
108// ===== the demo story: "The Family Garden" ==========================
109// pc layout (fixed targets, asserted by the KAT):
110// 0 say 1 say 2 CHOICE(tomatoes->3, sunflowers->6)
111// 3 say(+tend) 4 add 5 jump 9
112// 6 say(+tend) 7 add 8 jump 9
113// 9 say 10 CHOICE(water->11, play->14)
114// 11 add 12 say 13 jump 16
115// 14 say 15 jump 16
116// 16 ifgoto(tend>=2 -> 19)
117// 17 end (sprout ending) [tend < 2]
118// 19 say 20 end (bloom ending) [tend >= 2]
119const VN_VAR_TEND: i64 = 0
120
121func nx_vn_build_demo_story(out_nops: *i64) -> *i64 {
122 let ops: *i64 = sys_mmap(8 * VN_OP_STRIDE * 32) as *i64
123 var pc: i64 = 0
124 pc = vn_emit_say(ops, pc, VN_SPK_NARRATOR, "Saturday morning. The garden boxes are empty, the soil dark and ready." as *u8)
125 pc = vn_emit_say(ops, pc, VN_SPK_MOM, "We have room for one more planting before summer. What should we grow?" as *u8)
126 pc = vn_emit_choice2(ops, pc, "Plant tomatoes" as *u8, 3, "Plant sunflowers" as *u8, 6)
127 pc = vn_emit_say(ops, pc, VN_SPK_KID, "Tomatoes! Then we can make sauce like grandma does." as *u8) // 3
128 pc = vn_emit_add(ops, pc, VN_VAR_TEND, 1) // 4
129 pc = vn_emit_jump(ops, pc, 9) // 5
130 pc = vn_emit_say(ops, pc, VN_SPK_KID, "Sunflowers! The tall ones that look over the fence." as *u8) // 6
131 pc = vn_emit_add(ops, pc, VN_VAR_TEND, 1) // 7
132 pc = vn_emit_jump(ops, pc, 9) // 8
133 pc = vn_emit_say(ops, pc, VN_SPK_NARRATOR, "Seeds in. The afternoon stretches out, warm and slow." as *u8) // 9
134 pc = vn_emit_choice2(ops, pc, "Water the seeds before dinner" as *u8, 11, "Run off and play" as *u8, 14) // 10
135 pc = vn_emit_add(ops, pc, VN_VAR_TEND, 1) // 11
136 pc = vn_emit_say(ops, pc, VN_SPK_MOM, "Good eye. A seed you tend is a seed that answers." as *u8) // 12
137 pc = vn_emit_jump(ops, pc, 16) // 13
138 pc = vn_emit_say(ops, pc, VN_SPK_NARRATOR, "The seeds wait quietly in the dry ground." as *u8) // 14
139 pc = vn_emit_jump(ops, pc, 16) // 15
140 pc = vn_emit_ifgoto(ops, pc, VN_VAR_TEND, 2, 19) // 16
141 pc = vn_emit_end(ops, pc, "Weeks later: a few thin sprouts. Next season, we will remember to water." as *u8) // 17
142 pc = vn_emit_say(ops, pc, VN_SPK_NARRATOR, "unreachable" as *u8) // 18 (pad)
143 pc = vn_emit_say(ops, pc, VN_SPK_KID, "Look! LOOK! They came up!" as *u8) // 19
144 pc = vn_emit_end(ops, pc, "The garden answers: rows of green reaching for the fence. It was very good." as *u8) // 20
145 out_nops[0] = pc
146 return ops
147}
148
149// ===== engine core ==================================================
150// settle: run silent ops (SET/ADD/IFGOTO/JUMP) until SAY/CHOICE/END.
151func vn_settle(s: *i64) -> i64 {
152 let ops: *i64 = s[VN_S_SCRIPT] as *i64
153 var guard: i64 = 0
154 while guard < 256 {
155 let o: i64 = s[VN_S_PC] * VN_OP_STRIDE
156 let k: i64 = ops[o]
157 if k == VN_OP_SAY { return 0 }
158 if k == VN_OP_CHOICE { return 0 }
159 if k == VN_OP_END { s[VN_S_ENDED] = 1; return 0 }
160 if k == VN_OP_SET { s[VN_S_VARS + ops[o+1]] = ops[o+2]; s[VN_S_PC] = s[VN_S_PC] + 1 }
161 if k == VN_OP_ADD { s[VN_S_VARS + ops[o+1]] = s[VN_S_VARS + ops[o+1]] + ops[o+2]; s[VN_S_PC] = s[VN_S_PC] + 1 }
162 if k == VN_OP_IFGOTO {
163 if s[VN_S_VARS + ops[o+1]] >= ops[o+2] { s[VN_S_PC] = ops[o+3] }
164 if s[VN_S_VARS + ops[o+1]] < ops[o+2] { s[VN_S_PC] = s[VN_S_PC] + 1 }
165 }
166 if k == VN_OP_JUMP { s[VN_S_PC] = ops[o+1] }
167 guard = guard + 1
168 }
169 return 0 - 1 // runaway script (authoring bug); state still sane
170}
171
172func nx_vn_new(ops: *i64, nops: i64) -> *i64 {
173 let s: *i64 = sys_mmap(8 * VN_S_STRIDE) as *i64
174 s[VN_S_SCRIPT] = ops as i64
175 s[VN_S_NOPS] = nops
176 s[VN_S_PC] = 0
177 s[VN_S_STEPS] = 0
178 s[VN_S_ENDED] = 0
179 var i: i64 = 0
180 while i < VN_N_VARS { s[VN_S_VARS + i] = 0; i = i + 1 }
181 vn_settle(s)
182 return s
183}
184
185func nx_vn_kind(s: *i64) -> i64 {
186 let ops: *i64 = s[VN_S_SCRIPT] as *i64
187 return ops[s[VN_S_PC] * VN_OP_STRIDE]
188}
189func nx_vn_op_field(s: *i64, f: i64) -> i64 {
190 let ops: *i64 = s[VN_S_SCRIPT] as *i64
191 return ops[s[VN_S_PC] * VN_OP_STRIDE + f]
192}
193func nx_vn_ended(s: *i64) -> i64 { return s[VN_S_ENDED] }
194func nx_vn_var(s: *i64, i: i64) -> i64 { return s[VN_S_VARS + i] }
195
196// advance past a SAY (the "continue" click). No-op on CHOICE/END.
197func nx_vn_advance(s: *i64) -> i64 {
198 if s[VN_S_ENDED] == 1 { return 0 }
199 if nx_vn_kind(s) != VN_OP_SAY { return 0 }
200 s[VN_S_PC] = s[VN_S_PC] + 1
201 s[VN_S_STEPS] = s[VN_S_STEPS] + 1
202 vn_settle(s)
203 return 1
204}
205
206// pick choice i (the choice click). No-op unless on a CHOICE with valid i.
207func nx_vn_choose(s: *i64, i: i64) -> i64 {
208 if s[VN_S_ENDED] == 1 { return 0 }
209 if nx_vn_kind(s) != VN_OP_CHOICE { return 0 }
210 let n: i64 = nx_vn_op_field(s, 1)
211 if i < 0 { return 0 }
212 if i >= n { return 0 }
213 s[VN_S_PC] = nx_vn_op_field(s, 3 + i * 2)
214 s[VN_S_STEPS] = s[VN_S_STEPS] + 1
215 vn_settle(s)
216 return 1
217}
218
219// deterministic FNV-style hash over (pc, ended, steps, vars)
220func nx_vn_state_hash(s: *i64) -> i64 {
221 var h: i64 = VN_MAGIC_1469598103934665603
222 h = (h ^ s[VN_S_PC]) * VN_MAGIC_1099511628211
223 h = (h ^ s[VN_S_ENDED]) * VN_MAGIC_1099511628211
224 h = (h ^ s[VN_S_STEPS]) * VN_MAGIC_1099511628211
225 var i: i64 = 0
226 while i < VN_N_VARS { h = (h ^ s[VN_S_VARS + i]) * VN_MAGIC_1099511628211; i = i + 1 }
227 return h
228}
229
230// ===== browser exports (nishi-host mount() convention) ==============
231const VN_RENDER_FD: i64 = 99
232
233func vn_out(s: *u8) -> i64 {
234 var n: i64 = 0
235 while s[n] != (0 as u8) { n = n + 1 }
236 sys_write(VN_RENDER_FD, s, n)
237 return 0
238}
239
240func nx_vn_init() -> *i64 {
241 let nops_box: *i64 = sys_mmap(8) as *i64
242 let ops: *i64 = nx_vn_build_demo_story(nops_box)
243 return nx_vn_new(ops, nops_box[0])
244}
245
246// scene HTML to fd 99. data-action: 1=advance, 2=choose(arg0), 3=restart.
247func nx_vn_render(s: *i64) -> i64 {
248 vn_out("<div class=\"vn-scene\">" as *u8)
249 let k: i64 = nx_vn_kind(s)
250 if k == VN_OP_SAY {
251 let spk: i64 = nx_vn_op_field(s, 1)
252 let name: *u8 = nx_vn_speaker_name(spk)
253 if name[0] != (0 as u8) {
254 vn_out("<div class=\"vn-speaker\">" as *u8)
255 vn_out(name)
256 vn_out("</div>" as *u8)
257 }
258 vn_out("<p class=\"vn-text\">" as *u8)
259 vn_out(nx_vn_op_field(s, 2) as *u8)
260 vn_out("</p><button class=\"vn-btn\" data-action=\"1\">Continue →</button>" as *u8)
261 }
262 if k == VN_OP_CHOICE {
263 vn_out("<p class=\"vn-text vn-prompt\">What do you do?</p>" as *u8)
264 let n: i64 = nx_vn_op_field(s, 1)
265 var i: i64 = 0
266 while i < n {
267 vn_out("<button class=\"vn-btn vn-choice\" data-action=\"2\" data-arg0=\"" as *u8)
268 if i == 0 { vn_out("0" as *u8) }
269 if i == 1 { vn_out("1" as *u8) }
270 if i == 2 { vn_out("2" as *u8) }
271 vn_out("\">" as *u8)
272 vn_out(nx_vn_op_field(s, 2 + i * 2) as *u8)
273 vn_out("</button>" as *u8)
274 i = i + 1
275 }
276 }
277 if k == VN_OP_END {
278 vn_out("<p class=\"vn-text vn-ending\">" as *u8)
279 vn_out(nx_vn_op_field(s, 1) as *u8)
280 vn_out("</p><button class=\"vn-btn\" data-action=\"3\">Play again ↻</button>" as *u8)
281 }
282 vn_out("</div>" as *u8)
283 return 0
284}
285
286func nx_vn_action(s: *i64, action: i64, a0: i64, a1: i64, a2: i64, a3: i64) -> i64 {
287 if action == 1 { return nx_vn_advance(s) }
288 if action == 2 { return nx_vn_choose(s, a0) }
289 if action == 3 {
290 // restart: rebuild pristine state IN PLACE (mount holds the pointer)
291 s[VN_S_PC] = 0
292 s[VN_S_STEPS] = 0
293 s[VN_S_ENDED] = 0
294 var i: i64 = 0
295 while i < VN_N_VARS { s[VN_S_VARS + i] = 0; i = i + 1 }
296 vn_settle(s)
297 return 1
298 }
299 return 0
300}