code wiki / (root) / nx_visual_novel.nx

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 &rarr;</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 &#8635;</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}