nx_weather_pattern.nx source
↩ module page · 815 lines · 35811 B
1// nx_weather_pattern.nx -- spatial + temporal weather field.
2//
3// Seventh demonstration of the kind-specific-generator cardinal
4// `feedback-kind-specific-generators-not-broad-noise`. Weather is
5// neither static nor noise -- it has REGIONAL COHERENCE (a single
6// storm cell covers tens of kilometres) and TEMPORAL CYCLES (weeks,
7// seasons). Pure FBM noise gets neither right.
8//
9// Weather-internal logic in v1:
10// - World partitioned into "weather cells" (default 50 km square).
11// All voxels inside one cell share the same weather kind at a
12// given moment.
13// - Time cycles every ~7 game-days: weather changes deterministically
14// per (cell, week) hash. Storms come and go.
15// - 8 sealed-enum weather kinds with characteristic parameters
16// (precipitation, temperature offset, visibility, wind speed,
17// fog density).
18//
19// FULL CAPABILITY (per feedback-maximum-capability-no-simplification
20// cardinal, 2026-05-16):
21// - Smooth spatial transitions via bilinear-blend across the 4
22// surrounding cell centers (nx_weather_params_smooth)
23// - Per-biome weather distribution (nx_weather_kind_at_biome
24// reweights the kind histogram by biome -- rainforest gets
25// RAIN more often; desert gets HEATWAVE; arctic gets BLIZZARD)
26// - Multi-tick weather evolution: 15% ramp-in + 70% steady + 15%
27// ramp-out within each cycle blends previous, current, next
28// cycle params (nx_weather_evolution_factor + smooth-params)
29// - Wind direction vector (Q14 unit vec dx/dy) per cell+cycle,
30// hashed to one of 16 compass directions; speed from kind params
31// - Lightning discrete events for STORM/BLIZZARD: position +
32// timestamp via deterministic per-cycle hash; composable with
33// nx_world_event
34//
35// Loss audit: cell-based kind discretisation loses sub-cell weather
36// VARIETY (acceptable since real weather IS regionally coherent --
37// you can't have rain and sun side-by-side in the same valley).
38// PARAMS, by contrast, smooth across cells via bilinear blending so
39// renderer doesn't get sharp transitions.
40//
41// genealogy_id: lorenz_1963_deterministic_nonperiodic +
42// koppen_1900_climate_classification +
43// wmo_2017_weather_kind_taxonomy +
44// browning_1986_storm_lifecycle
45// lineage_id: nx_weather_pattern_smooth_biome_wind_v2
46
47// nx_safety_envelope:
48// intended_use: AUTO_APPLIED -- primitive-specific tuning queued
49// sil_target: SIL1
50// evidence: [bulk_applied_2026-05-16, see-file-comment-for-detail]
51// verdict: NOT_YET_EVALUATED
52
53import "nx_syscalls.nx"
54import "nx_tier.nx"
55import "nx_camera_q14.nx"
56const NX_MAGIC_2654435761: i64 = 2654435761
57const NX_MAGIC_1597334677: i64 = 1597334677
58const NX_MAGIC_951712399: i64 = 951712399
59const NX_MAGIC_7919: i64 = 7919
60const NX_MAGIC_25000: i64 = 25000
61const NX_MAGIC_49999: i64 = 49999
62const NX_MAGIC_5000: i64 = 5000
63
64// ===== Q14 ==========================================================
65const NX_WP_Q: nx_int = 16384
66
67// Park-Miller LCG (matches the other procgen primitives).
68const NX_WP_LCG_A: nx_int = 48271
69const NX_WP_LCG_M: nx_int = 2147483647
70
71// Default weather-cell size in Q14 metres. 50 km matches mesoscale
72// weather cell size (the size at which a single storm front covers
73// roughly one cell).
74const NX_WP_DEFAULT_CELL_SIZE_Q14: nx_int = 50000
75
76// Default temporal cycle in Q14 days. 7 days matches typical
77// synoptic-scale weather rotation (a storm system persists ~3-5
78// days; cycles refresh weekly).
79const NX_WP_DEFAULT_CYCLE_Q14_DAYS: nx_int = 7
80
81// ===== Weather-kind sealed enum =====================================
82const NX_WEATHER_CLEAR: nx_int = 0 // sunny, no precip
83const NX_WEATHER_OVERCAST: nx_int = 1 // cloudy, no precip
84const NX_WEATHER_RAIN: nx_int = 2 // moderate liquid precipitation
85const NX_WEATHER_SNOW: nx_int = 3 // moderate frozen precipitation
86const NX_WEATHER_FOG: nx_int = 4 // low visibility, no precip
87const NX_WEATHER_STORM: nx_int = 5 // heavy rain + wind + lightning
88const NX_WEATHER_HEATWAVE: nx_int = 6 // clear + extreme temperature
89const NX_WEATHER_BLIZZARD: nx_int = 7 // heavy snow + extreme wind + low vis
90
91const NX_WEATHER_KIND_COUNT: nx_int = 8
92
93// ===== Parameter offsets ============================================
94// nx_weather_params writes 5 fields per kind.
95const NX_WP_PARAM_PRECIP: nx_int = 0 // Q14 [0, Q]: 0 = dry, Q = monsoon
96const NX_WP_PARAM_TEMP_OFFSET: nx_int = 1 // signed Q14 degrees C from baseline
97const NX_WP_PARAM_VISIBILITY: nx_int = 2 // Q14 [0, Q]: 0 = none, Q = clear
98const NX_WP_PARAM_WIND_SPEED: nx_int = 3 // Q14 [0, Q]: 0 = still, Q = hurricane
99const NX_WP_PARAM_FOG_DENSITY: nx_int = 4 // Q14 [0, Q]: 0 = none, Q = pea soup
100
101const NX_WP_PARAM_COUNT: nx_int = 5
102
103// ===== Validity predicates ==========================================
104func nx_weather_kind_is_valid(k: nx_int) -> nx_int {
105 if k == NX_WEATHER_CLEAR { return 1 }
106 if k == NX_WEATHER_OVERCAST { return 1 }
107 if k == NX_WEATHER_RAIN { return 1 }
108 if k == NX_WEATHER_SNOW { return 1 }
109 if k == NX_WEATHER_FOG { return 1 }
110 if k == NX_WEATHER_STORM { return 1 }
111 if k == NX_WEATHER_HEATWAVE { return 1 }
112 if k == NX_WEATHER_BLIZZARD { return 1 }
113 return 0
114}
115
116// ===== Hash mixer ===================================================
117func _wp_hash(seed: nx_int, a: nx_int, b: nx_int, c: nx_int) -> nx_int {
118 var h: nx_int = seed
119 h = (h * NX_WP_LCG_A + a * NX_MAGIC_2654435761) % NX_WP_LCG_M
120 if h < 0 { h = h + NX_WP_LCG_M }
121 h = (h * NX_WP_LCG_A + b * NX_MAGIC_1597334677) % NX_WP_LCG_M
122 if h < 0 { h = h + NX_WP_LCG_M }
123 h = (h * NX_WP_LCG_A + c * NX_MAGIC_951712399) % NX_WP_LCG_M
124 if h < 0 { h = h + NX_WP_LCG_M }
125 return h
126}
127
128// ===== Weather-kind lookup at (region, time) =======================
129// World coords + game time -> sealed-enum weather kind.
130//
131// Caller controls cell size and cycle length (or pass 0 to use the
132// defaults).
133//
134// Weather distribution biased so CLEAR + OVERCAST are most common
135// (50% combined); STORM + BLIZZARD rare (~10% combined). This is
136// a v1 approximation; v2 routes by biome.
137func nx_weather_kind_at(
138 seed: nx_int,
139 time_q14_days: nx_int,
140 x_q14: nx_int,
141 y_q14: nx_int,
142 cell_size_q14: nx_int,
143 cycle_q14_days: nx_int
144) -> nx_int {
145 var cs: nx_int = cell_size_q14
146 if cs <= 0 { cs = NX_WP_DEFAULT_CELL_SIZE_Q14 }
147 var cy: nx_int = cycle_q14_days
148 if cy <= 0 { cy = NX_WP_DEFAULT_CYCLE_Q14_DAYS * NX_WP_Q }
149
150 let cell_x: nx_int = x_q14 / cs
151 let cell_y: nx_int = y_q14 / cs
152 let cycle: nx_int = time_q14_days / cy
153
154 let h: nx_int = _wp_hash(seed, cell_x + cell_y * NX_MAGIC_7919, cycle, 1)
155 // Distribution: CLEAR 30% / OVERCAST 20% / RAIN 15% / SNOW 10% /
156 // FOG 10% / STORM 6% / HEATWAVE 5% / BLIZZARD 4%
157 let bucket: nx_int = h % 100
158 if bucket < 30 { return NX_WEATHER_CLEAR }
159 if bucket < 50 { return NX_WEATHER_OVERCAST }
160 if bucket < 65 { return NX_WEATHER_RAIN }
161 if bucket < 75 { return NX_WEATHER_SNOW }
162 if bucket < 85 { return NX_WEATHER_FOG }
163 if bucket < 91 { return NX_WEATHER_STORM }
164 if bucket < 96 { return NX_WEATHER_HEATWAVE }
165 return NX_WEATHER_BLIZZARD
166}
167
168// ===== Per-kind parameters =========================================
169// Writes 5 Q14 fields to out[0..4]:
170// PRECIP, TEMP_OFFSET, VISIBILITY, WIND_SPEED, FOG_DENSITY.
171//
172// Temperature offset is signed Q14 degrees C from baseline.
173// All others are Q14 [0, Q].
174func nx_weather_params(kind: nx_int, out: *i64) {
175 let q: nx_int = NX_WP_Q
176 // Default (CLEAR-like).
177 out[NX_WP_PARAM_PRECIP] = 0
178 out[NX_WP_PARAM_TEMP_OFFSET] = 0
179 out[NX_WP_PARAM_VISIBILITY] = q
180 out[NX_WP_PARAM_WIND_SPEED] = q / 8
181 out[NX_WP_PARAM_FOG_DENSITY] = 0
182
183 if kind == NX_WEATHER_CLEAR {
184 return // defaults
185 }
186 if kind == NX_WEATHER_OVERCAST {
187 out[NX_WP_PARAM_VISIBILITY] = q * 8 / 10
188 out[NX_WP_PARAM_TEMP_OFFSET] = 0 - q
189 return
190 }
191 if kind == NX_WEATHER_RAIN {
192 out[NX_WP_PARAM_PRECIP] = q * 6 / 10
193 out[NX_WP_PARAM_TEMP_OFFSET] = 0 - 2 * q
194 out[NX_WP_PARAM_VISIBILITY] = q * 6 / 10
195 out[NX_WP_PARAM_WIND_SPEED] = q * 3 / 10
196 return
197 }
198 if kind == NX_WEATHER_SNOW {
199 out[NX_WP_PARAM_PRECIP] = q * 4 / 10
200 out[NX_WP_PARAM_TEMP_OFFSET] = 0 - 8 * q
201 out[NX_WP_PARAM_VISIBILITY] = q * 5 / 10
202 out[NX_WP_PARAM_WIND_SPEED] = q * 2 / 10
203 return
204 }
205 if kind == NX_WEATHER_FOG {
206 out[NX_WP_PARAM_PRECIP] = 0
207 out[NX_WP_PARAM_TEMP_OFFSET] = 0 - q
208 out[NX_WP_PARAM_VISIBILITY] = q * 2 / 10
209 out[NX_WP_PARAM_WIND_SPEED] = q / 20
210 out[NX_WP_PARAM_FOG_DENSITY] = q * 8 / 10
211 return
212 }
213 if kind == NX_WEATHER_STORM {
214 out[NX_WP_PARAM_PRECIP] = q
215 out[NX_WP_PARAM_TEMP_OFFSET] = 0 - 5 * q
216 out[NX_WP_PARAM_VISIBILITY] = q * 3 / 10
217 out[NX_WP_PARAM_WIND_SPEED] = q * 9 / 10
218 out[NX_WP_PARAM_FOG_DENSITY] = q * 4 / 10
219 return
220 }
221 if kind == NX_WEATHER_HEATWAVE {
222 out[NX_WP_PARAM_PRECIP] = 0
223 out[NX_WP_PARAM_TEMP_OFFSET] = 15 * q
224 out[NX_WP_PARAM_VISIBILITY] = q * 9 / 10
225 out[NX_WP_PARAM_WIND_SPEED] = q / 20
226 out[NX_WP_PARAM_FOG_DENSITY] = q / 10 // heat haze
227 return
228 }
229 if kind == NX_WEATHER_BLIZZARD {
230 out[NX_WP_PARAM_PRECIP] = q * 9 / 10
231 out[NX_WP_PARAM_TEMP_OFFSET] = 0 - 20 * q
232 out[NX_WP_PARAM_VISIBILITY] = q / 10
233 out[NX_WP_PARAM_WIND_SPEED] = q
234 out[NX_WP_PARAM_FOG_DENSITY] = q * 7 / 10
235 return
236 }
237}
238
239// ===== Combined query ===============================================
240// Convenience: kind + parameters in one call.
241func nx_weather_at(
242 seed: nx_int,
243 time_q14_days: nx_int,
244 x_q14: nx_int,
245 y_q14: nx_int,
246 cell_size_q14: nx_int,
247 cycle_q14_days: nx_int,
248 params_out: *i64
249) -> nx_int {
250 let kind: nx_int = nx_weather_kind_at(seed, time_q14_days, x_q14, y_q14, cell_size_q14, cycle_q14_days)
251 nx_weather_params(kind, params_out)
252 return kind
253}
254
255// ===== Biome IDs (matching nx_biome_classifier convention) =========
256const NX_BIOME_TUNDRA: nx_int = 0
257const NX_BIOME_BOREAL_FOREST: nx_int = 1
258const NX_BIOME_GRASSLAND: nx_int = 4
259const NX_BIOME_TEMPERATE_FOREST: nx_int = 5
260const NX_BIOME_DESERT: nx_int = 8
261const NX_BIOME_TROPICAL_RAINFOREST: nx_int = 11
262const NX_BIOME_SNOW: nx_int = 12
263const NX_BIOME_ICE: nx_int = 13
264
265// ===== Biome-routed kind selection ==================================
266// Each biome reweights the 8-kind distribution. Total per row = 100.
267// Buckets used the same way as the base distribution: cumulative
268// running sum, then test bucket < threshold.
269//
270// Layout: per-biome cumulative bucket boundaries for CLEAR, OVERCAST,
271// RAIN, SNOW, FOG, STORM, HEATWAVE. Last (BLIZZARD) is implicit.
272//
273// Caller passes a biome ID; if unknown -> default to baseline
274// distribution (matching nx_weather_kind_at).
275func _wp_biome_cumulative(biome: nx_int, slot: nx_int) -> nx_int {
276 // Slots: 0=CLEAR, 1=OVERCAST, 2=RAIN, 3=SNOW, 4=FOG, 5=STORM, 6=HEATWAVE
277 // Each row is the CUMULATIVE bucket-edge in 0..100.
278 if biome == NX_BIOME_DESERT {
279 // DESERT: lots of CLEAR + HEATWAVE; almost no SNOW/BLIZZARD.
280 if slot == 0 { return 55 } // CLEAR 55
281 if slot == 1 { return 70 } // +OVERCAST 15
282 if slot == 2 { return 73 } // +RAIN 3
283 if slot == 3 { return 73 } // +SNOW 0
284 if slot == 4 { return 75 } // +FOG 2
285 if slot == 5 { return 77 } // +STORM 2 (dust storm)
286 if slot == 6 { return 99 } // +HEATWAVE 22
287 return 100 // +BLIZZARD 1
288 }
289 if biome == NX_BIOME_TROPICAL_RAINFOREST {
290 // RAINFOREST: lots of RAIN + STORM; no SNOW/BLIZZARD.
291 if slot == 0 { return 10 }
292 if slot == 1 { return 25 } // +OVERCAST 15
293 if slot == 2 { return 65 } // +RAIN 40
294 if slot == 3 { return 65 }
295 if slot == 4 { return 80 } // +FOG 15
296 if slot == 5 { return 95 } // +STORM 15
297 if slot == 6 { return 100 } // +HEATWAVE 5
298 return 100
299 }
300 if biome == NX_BIOME_TUNDRA {
301 // TUNDRA: SNOW + BLIZZARD common.
302 if slot == 0 { return 20 }
303 if slot == 1 { return 35 } // +OVERCAST 15
304 if slot == 2 { return 38 } // +RAIN 3
305 if slot == 3 { return 68 } // +SNOW 30
306 if slot == 4 { return 78 } // +FOG 10
307 if slot == 5 { return 82 } // +STORM 4
308 if slot == 6 { return 82 }
309 return 100 // +BLIZZARD 18
310 }
311 if biome == NX_BIOME_ICE {
312 // ICE: pure cold weather, BLIZZARD dominant.
313 if slot == 0 { return 10 }
314 if slot == 1 { return 20 }
315 if slot == 2 { return 20 }
316 if slot == 3 { return 50 } // +SNOW 30
317 if slot == 4 { return 60 }
318 if slot == 5 { return 62 }
319 if slot == 6 { return 62 }
320 return 100 // +BLIZZARD 38
321 }
322 if biome == NX_BIOME_BOREAL_FOREST {
323 // BOREAL: cool + wet, regular SNOW, occasional BLIZZARD.
324 if slot == 0 { return 25 }
325 if slot == 1 { return 45 }
326 if slot == 2 { return 60 }
327 if slot == 3 { return 75 }
328 if slot == 4 { return 87 }
329 if slot == 5 { return 92 }
330 if slot == 6 { return 95 }
331 return 100
332 }
333 // Baseline: TEMPERATE_FOREST, GRASSLAND, SNOW, unknown.
334 if slot == 0 { return 30 }
335 if slot == 1 { return 50 }
336 if slot == 2 { return 65 }
337 if slot == 3 { return 75 }
338 if slot == 4 { return 85 }
339 if slot == 5 { return 91 }
340 if slot == 6 { return 96 }
341 return 100
342}
343
344func _wp_pick_kind_from_bucket(biome: nx_int, bucket: nx_int) -> nx_int {
345 if bucket < _wp_biome_cumulative(biome, 0) { return NX_WEATHER_CLEAR }
346 if bucket < _wp_biome_cumulative(biome, 1) { return NX_WEATHER_OVERCAST }
347 if bucket < _wp_biome_cumulative(biome, 2) { return NX_WEATHER_RAIN }
348 if bucket < _wp_biome_cumulative(biome, 3) { return NX_WEATHER_SNOW }
349 if bucket < _wp_biome_cumulative(biome, 4) { return NX_WEATHER_FOG }
350 if bucket < _wp_biome_cumulative(biome, 5) { return NX_WEATHER_STORM }
351 if bucket < _wp_biome_cumulative(biome, 6) { return NX_WEATHER_HEATWAVE }
352 return NX_WEATHER_BLIZZARD
353}
354
355// Biome-routed kind selection: uses the biome's weighted distribution.
356func nx_weather_kind_at_biome(
357 seed: nx_int,
358 time_q14_days: nx_int,
359 x_q14: nx_int,
360 y_q14: nx_int,
361 cell_size_q14: nx_int,
362 cycle_q14_days: nx_int,
363 biome: nx_int
364) -> nx_int {
365 var cs: nx_int = cell_size_q14
366 if cs <= 0 { cs = NX_WP_DEFAULT_CELL_SIZE_Q14 }
367 var cy: nx_int = cycle_q14_days
368 if cy <= 0 { cy = NX_WP_DEFAULT_CYCLE_Q14_DAYS * NX_WP_Q }
369 let cell_x: nx_int = x_q14 / cs
370 let cell_y: nx_int = y_q14 / cs
371 let cycle: nx_int = time_q14_days / cy
372 let h: nx_int = _wp_hash(seed, cell_x + cell_y * NX_MAGIC_7919, cycle, 2 + biome)
373 let bucket: nx_int = h % 100
374 return _wp_pick_kind_from_bucket(biome, bucket)
375}
376
377// ===== Multi-tick evolution =========================================
378// Returns the weight of the CURRENT cycle's weather at fractional
379// progress `phase_q14` in [0, Q] within the cycle. Envelope:
380// phase < 0.15Q : ramp from 0 -> 1 (prev cycle still has weight)
381// 0.15 <= phase < 0.85 : 1.0 (current weather is steady)
382// phase >= 0.85 : ramp 1 -> 0 (next cycle takes over)
383// Returns Q14.
384func _wp_evolution_factor(phase_q14: nx_int) -> nx_int {
385 let q: nx_int = NX_WP_Q
386 let ramp_in: nx_int = q * 15 / 100 // 0.15Q
387 let ramp_out: nx_int = q * 85 / 100 // 0.85Q
388 if phase_q14 < 0 { return 0 }
389 if phase_q14 < ramp_in {
390 return (phase_q14 * q) / ramp_in
391 }
392 if phase_q14 < ramp_out {
393 return q
394 }
395 if phase_q14 < q {
396 // Ramp out: 1 -> 0 over [0.85, 1.0]
397 let remaining: nx_int = q - phase_q14
398 let span: nx_int = q - ramp_out
399 return (remaining * q) / span
400 }
401 return 0
402}
403
404func nx_weather_evolution_factor(
405 time_q14_days: nx_int,
406 cycle_q14_days: nx_int
407) -> nx_int {
408 var cy: nx_int = cycle_q14_days
409 if cy <= 0 { cy = NX_WP_DEFAULT_CYCLE_Q14_DAYS * NX_WP_Q }
410 let phase: nx_int = (time_q14_days % cy) * NX_WP_Q / cy
411 return _wp_evolution_factor(phase)
412}
413
414// ===== Smooth (bilinear) params =====================================
415// Bilinear-blend params across the 4 surrounding cell centers. Cell
416// centers are at ((cx + 0.5) * cell_size, (cy + 0.5) * cell_size).
417// Query point (x, y) falls in some 2x2 cell-center quad; weight each
418// corner's params by area.
419//
420// Also blends across cycle boundaries via the evolution factor: when
421// the current cycle is ramping in, params are weighted mix of (prev,
422// curr); when ramping out, mix of (curr, next).
423//
424// Writes 5 Q14 params to params_out.
425func nx_weather_params_smooth(
426 seed: nx_int,
427 time_q14_days: nx_int,
428 x_q14: nx_int,
429 y_q14: nx_int,
430 cell_size_q14: nx_int,
431 cycle_q14_days: nx_int,
432 biome: nx_int,
433 params_out: *i64
434) {
435 let q: nx_int = NX_WP_Q
436 var cs: nx_int = cell_size_q14
437 if cs <= 0 { cs = NX_WP_DEFAULT_CELL_SIZE_Q14 }
438 var cy: nx_int = cycle_q14_days
439 if cy <= 0 { cy = NX_WP_DEFAULT_CYCLE_Q14_DAYS * NX_WP_Q }
440 let half_cs: nx_int = cs / 2
441
442 // Cell containing (x - half_cs, y - half_cs) is the NW corner of
443 // the bilinear quad whose 4 corners are cell-center centers.
444 let shifted_x: nx_int = x_q14 - half_cs
445 let shifted_y: nx_int = y_q14 - half_cs
446 var ax_cell: nx_int = shifted_x / cs
447 var ay_cell: nx_int = shifted_y / cs
448 if shifted_x < 0 {
449 if shifted_x % cs != 0 { ax_cell = ax_cell - 1 }
450 }
451 if shifted_y < 0 {
452 if shifted_y % cs != 0 { ay_cell = ay_cell - 1 }
453 }
454 let bx_cell: nx_int = ax_cell + 1
455 let by_cell: nx_int = ay_cell + 1
456
457 // Fractional position within the cell-center quad. fx in [0, Q].
458 var rem_x: nx_int = shifted_x - ax_cell * cs
459 if rem_x < 0 { rem_x = rem_x + cs }
460 var rem_y: nx_int = shifted_y - ay_cell * cs
461 if rem_y < 0 { rem_y = rem_y + cs }
462 let fx_q14: nx_int = (rem_x * q) / cs
463 let fy_q14: nx_int = (rem_y * q) / cs
464
465 // Cycle phase + evolution factor.
466 let cycle: nx_int = time_q14_days / cy
467 let evol: nx_int = nx_weather_evolution_factor(time_q14_days, cy)
468 let phase: nx_int = (time_q14_days % cy) * q / cy
469
470 // Cycle blend: when phase < 0.15Q -> mix prev+curr; >0.85Q -> mix
471 // curr+next. Else: pure curr.
472 var prev_w: nx_int = 0
473 var next_w: nx_int = 0
474 var curr_w: nx_int = q
475 let ramp_in: nx_int = q * 15 / 100
476 let ramp_out: nx_int = q * 85 / 100
477 if phase < ramp_in {
478 // evol is curr_w; prev gets (q - evol).
479 curr_w = evol
480 prev_w = q - evol
481 }
482 if phase >= ramp_out {
483 curr_w = evol
484 next_w = q - evol
485 }
486
487 // Scratch param buffers for each of 4 cells across 3 cycles (12
488 // total). Allocate once.
489 let scratch: *i64 = (sys_mmap(12 * NX_WP_PARAM_COUNT * NX_SIZEOF_NX_INT)) as *i64
490
491 // For each of 4 cells x 3 cycles, fetch params.
492 var idx: nx_int = 0
493 var cell_i: nx_int = 0
494 while cell_i < 4 {
495 var cell_x: nx_int = ax_cell
496 var cell_y: nx_int = ay_cell
497 if cell_i == 1 { cell_x = bx_cell; cell_y = ay_cell }
498 if cell_i == 2 { cell_x = ax_cell; cell_y = by_cell }
499 if cell_i == 3 { cell_x = bx_cell; cell_y = by_cell }
500 var cyc_d: nx_int = 0 - 1
501 while cyc_d <= 1 {
502 let h: nx_int = _wp_hash(seed, cell_x + cell_y * NX_MAGIC_7919, cycle + cyc_d, 2 + biome)
503 let bucket: nx_int = h % 100
504 let kind: nx_int = _wp_pick_kind_from_bucket(biome, bucket)
505 let p_base: nx_int = idx * NX_WP_PARAM_COUNT
506 nx_weather_params(kind, (scratch as i64 + p_base * NX_SIZEOF_NX_INT) as *i64)
507 idx = idx + 1
508 cyc_d = cyc_d + 1
509 }
510 cell_i = cell_i + 1
511 }
512
513 // Bilinear weights for the 4 spatial corners.
514 let w_nw: nx_int = ((q - fx_q14) * (q - fy_q14)) / q
515 let w_ne: nx_int = (fx_q14 * (q - fy_q14)) / q
516 let w_sw: nx_int = ((q - fx_q14) * fy_q14) / q
517 let w_se: nx_int = (fx_q14 * fy_q14) / q
518
519 // For each param slot, weighted sum. Cell 0=NW prev/curr/next,
520 // cell 1=NE prev/curr/next, etc.
521 var slot: nx_int = 0
522 while slot < NX_WP_PARAM_COUNT {
523 var total: nx_int = 0
524 var ci: nx_int = 0
525 while ci < 4 {
526 let p_base: nx_int = ci * 3 * NX_WP_PARAM_COUNT
527 let p_prev: nx_int = scratch[p_base + 0 * NX_WP_PARAM_COUNT + slot]
528 let p_curr: nx_int = scratch[p_base + 1 * NX_WP_PARAM_COUNT + slot]
529 let p_next: nx_int = scratch[p_base + 2 * NX_WP_PARAM_COUNT + slot]
530 // Time-blend within this cell.
531 let time_blend: nx_int = (p_prev * prev_w + p_curr * curr_w + p_next * next_w) / q
532 var sw: nx_int = w_nw
533 if ci == 1 { sw = w_ne }
534 if ci == 2 { sw = w_sw }
535 if ci == 3 { sw = w_se }
536 total = total + (time_blend * sw) / q
537 ci = ci + 1
538 }
539 params_out[slot] = total
540 slot = slot + 1
541 }
542}
543
544// ===== Wind direction ===============================================
545// Per cell + cycle, the wind has a primary direction picked from one
546// of 16 compass bearings (0 deg = +X / East; 90 deg = +Y / South).
547// Hash bucket in [0, 16). Returns unit-vector dx, dy in Q14 to caller
548// buffer, and writes the speed in Q14 as the return value.
549func nx_weather_wind_at(
550 seed: nx_int,
551 time_q14_days: nx_int,
552 x_q14: nx_int,
553 y_q14: nx_int,
554 cell_size_q14: nx_int,
555 cycle_q14_days: nx_int,
556 biome: nx_int,
557 dir_out: *i64
558) -> nx_int {
559 var cs: nx_int = cell_size_q14
560 if cs <= 0 { cs = NX_WP_DEFAULT_CELL_SIZE_Q14 }
561 var cy: nx_int = cycle_q14_days
562 if cy <= 0 { cy = NX_WP_DEFAULT_CYCLE_Q14_DAYS * NX_WP_Q }
563 let cell_x: nx_int = x_q14 / cs
564 let cell_y: nx_int = y_q14 / cs
565 let cycle: nx_int = time_q14_days / cy
566 let h: nx_int = _wp_hash(seed, cell_x + cell_y * NX_MAGIC_7919, cycle, 1000)
567 let compass: nx_int = h % 16
568 // 16 directions -> deg = compass * 22.5 -> approximate to nearest
569 // integer; truncate the half-degree component (matches camera_q14
570 // 5-degree-resolution table sufficiently for the visual effect).
571 var deg: nx_int = compass * 22
572 if compass * 22 + (compass / 2) < 360 { deg = compass * 22 + (compass / 2) }
573 let dx: nx_int = nx_camera_cos_q14_deg(deg)
574 let dy: nx_int = nx_camera_sin_q14_deg(deg)
575 dir_out[0] = dx
576 dir_out[1] = dy
577 // Speed from kind params.
578 let kind: nx_int = nx_weather_kind_at_biome(seed, time_q14_days, x_q14, y_q14, cs, cy, biome)
579 let p: *i64 = (sys_mmap(NX_WP_PARAM_COUNT * NX_SIZEOF_NX_INT)) as *i64
580 nx_weather_params(kind, p)
581 return p[NX_WP_PARAM_WIND_SPEED]
582}
583
584// ===== Lightning ====================================================
585// Discrete lightning events fire during STORM / BLIZZARD weather kinds.
586// Per cycle + cell, generate up to 16 lightning strikes; each at a
587// deterministic position within the cell and time within the cycle.
588//
589// nx_weather_lightning_active_at(seed, t, x, y, cs, cy, biome, dt_q14):
590// returns 1 if a strike occurred at any (sx, sy) within distance
591// dt_q14 of (x, y) and within +/- dt_q14_days of t. 0 otherwise.
592//
593// Storm zones get 8-16 strikes per cycle (1-2 strikes per game day);
594// blizzards get 4-8. Others get 0.
595func nx_weather_lightning_active_at(
596 seed: nx_int,
597 time_q14_days: nx_int,
598 x_q14: nx_int,
599 y_q14: nx_int,
600 cell_size_q14: nx_int,
601 cycle_q14_days: nx_int,
602 biome: nx_int,
603 radius_q14: nx_int,
604 time_window_q14_days: nx_int
605) -> nx_int {
606 var cs: nx_int = cell_size_q14
607 if cs <= 0 { cs = NX_WP_DEFAULT_CELL_SIZE_Q14 }
608 var cy: nx_int = cycle_q14_days
609 if cy <= 0 { cy = NX_WP_DEFAULT_CYCLE_Q14_DAYS * NX_WP_Q }
610 let cell_x: nx_int = x_q14 / cs
611 let cell_y: nx_int = y_q14 / cs
612 let cycle: nx_int = time_q14_days / cy
613 let kind: nx_int = nx_weather_kind_at_biome(seed, time_q14_days, x_q14, y_q14, cs, cy, biome)
614 var n_strikes: nx_int = 0
615 if kind == NX_WEATHER_STORM { n_strikes = 12 }
616 if kind == NX_WEATHER_BLIZZARD { n_strikes = 6 }
617 if n_strikes == 0 { return 0 }
618
619 var s: nx_int = 0
620 while s < n_strikes {
621 let h_pos: nx_int = _wp_hash(seed + 17, cell_x + cell_y * NX_MAGIC_7919, cycle * 256 + s, 3)
622 let h_t: nx_int = _wp_hash(seed + 19, cell_x + cell_y * NX_MAGIC_7919, cycle * 256 + s, 4)
623 let h_pos2: nx_int = _wp_hash(seed + 23, cell_x + cell_y * NX_MAGIC_7919, cycle * 256 + s, 5)
624 let sx: nx_int = cell_x * cs + (h_pos % cs)
625 let sy: nx_int = cell_y * cs + (h_pos2 % cs)
626 let st: nx_int = cycle * cy + (h_t % cy)
627 let dx: nx_int = x_q14 - sx
628 let dy: nx_int = y_q14 - sy
629 let d2: nx_int = dx * dx + dy * dy
630 let r2: nx_int = radius_q14 * radius_q14
631 if d2 < r2 {
632 let dt: nx_int = time_q14_days - st
633 var adt: nx_int = dt
634 if adt < 0 { adt = 0 - adt }
635 if adt < time_window_q14_days { return 1 }
636 }
637 s = s + 1
638 }
639 return 0
640}
641
642// ===== Self-test ====================================================
643func main() -> i64 {
644 let q: nx_int = NX_WP_Q
645
646 // T1: Validity predicate.
647 if nx_weather_kind_is_valid(NX_WEATHER_CLEAR) != 1 { return __syscall(93, 1, 0, 0, 0, 0, 0) }
648 if nx_weather_kind_is_valid(NX_WEATHER_BLIZZARD) != 1 { return __syscall(93, 2, 0, 0, 0, 0, 0) }
649 if nx_weather_kind_is_valid(99) != 0 { return __syscall(93, 3, 0, 0, 0, 0, 0) }
650
651 // T2: Determinism -- same (seed, time, x, y) -> same kind.
652 let k_a: nx_int = nx_weather_kind_at(42, q, 100 * q, 200 * q, 0, 0)
653 let k_b: nx_int = nx_weather_kind_at(42, q, 100 * q, 200 * q, 0, 0)
654 if k_a != k_b { return __syscall(93, 10, 0, 0, 0, 0, 0) }
655
656 // T3: Within a single cell, weather is the same at the same time.
657 // Default cell size is 50000 Q14; query two coords inside one cell.
658 let k_in1: nx_int = nx_weather_kind_at(42, q, 100 * q, 200 * q, 0, 0)
659 let k_in2: nx_int = nx_weather_kind_at(42, q, 110 * q, 210 * q, 0, 0)
660 if k_in1 != k_in2 { return __syscall(93, 20, 0, 0, 0, 0, 0) }
661
662 // T4: Different time cycle -> probably different weather.
663 // Default cycle is 7 days * Q14. Bump time by 14 days (2 cycles)
664 // so we cross at least one cycle boundary.
665 let k_t1: nx_int = nx_weather_kind_at(42, q, 100 * q, 200 * q, 0, 0)
666 let k_t2: nx_int = nx_weather_kind_at(42, q + 14 * q, 100 * q, 200 * q, 0, 0)
667 var any_change_t: nx_int = 0
668 if k_t1 != k_t2 { any_change_t = 1 }
669 // Also try a few seeds; at least one should differ. Probabilistic
670 // but with 8 kinds + 100-bucket distribution the chance of all
671 // matching is below 1%.
672 let k_s1: nx_int = nx_weather_kind_at(0, q, 0, 0, 0, 0)
673 let k_s2: nx_int = nx_weather_kind_at(1, q, 0, 0, 0, 0)
674 let k_s3: nx_int = nx_weather_kind_at(2, q, 0, 0, 0, 0)
675 var any_change_s: nx_int = 0
676 if k_s1 != k_s2 { any_change_s = 1 }
677 if k_s2 != k_s3 { any_change_s = 1 }
678 if any_change_t == 0 {
679 if any_change_s == 0 { return __syscall(93, 30, 0, 0, 0, 0, 0) }
680 }
681
682 // T5: All returned kinds are valid sealed-enum values.
683 var trial: nx_int = 0
684 while trial < 32 {
685 let k: nx_int = nx_weather_kind_at(trial, q, trial * q, trial * 2 * q, 0, 0)
686 if nx_weather_kind_is_valid(k) != 1 { return __syscall(93, 40, 0, 0, 0, 0, 0) }
687 trial = trial + 1
688 }
689
690 // T6: Param lookup -- BLIZZARD has the largest wind + lowest temp.
691 let p_blizzard: *i64 = (sys_mmap(NX_WP_PARAM_COUNT * NX_SIZEOF_NX_INT)) as *i64
692 let p_clear: *i64 = (sys_mmap(NX_WP_PARAM_COUNT * NX_SIZEOF_NX_INT)) as *i64
693 let p_heat: *i64 = (sys_mmap(NX_WP_PARAM_COUNT * NX_SIZEOF_NX_INT)) as *i64
694 nx_weather_params(NX_WEATHER_BLIZZARD, p_blizzard)
695 nx_weather_params(NX_WEATHER_CLEAR, p_clear)
696 nx_weather_params(NX_WEATHER_HEATWAVE, p_heat)
697 if p_blizzard[NX_WP_PARAM_WIND_SPEED] <= p_clear[NX_WP_PARAM_WIND_SPEED] { return __syscall(93, 50, 0, 0, 0, 0, 0) }
698 if p_blizzard[NX_WP_PARAM_TEMP_OFFSET] >= p_clear[NX_WP_PARAM_TEMP_OFFSET] { return __syscall(93, 51, 0, 0, 0, 0, 0) }
699 if p_heat[NX_WP_PARAM_TEMP_OFFSET] <= p_clear[NX_WP_PARAM_TEMP_OFFSET] { return __syscall(93, 52, 0, 0, 0, 0, 0) }
700 if p_blizzard[NX_WP_PARAM_PRECIP] <= p_clear[NX_WP_PARAM_PRECIP] { return __syscall(93, 53, 0, 0, 0, 0, 0) }
701 if p_blizzard[NX_WP_PARAM_VISIBILITY] >= p_clear[NX_WP_PARAM_VISIBILITY] { return __syscall(93, 54, 0, 0, 0, 0, 0) }
702
703 // T7: STORM has higher wind than RAIN.
704 let p_rain: *i64 = (sys_mmap(NX_WP_PARAM_COUNT * NX_SIZEOF_NX_INT)) as *i64
705 let p_storm: *i64 = (sys_mmap(NX_WP_PARAM_COUNT * NX_SIZEOF_NX_INT)) as *i64
706 nx_weather_params(NX_WEATHER_RAIN, p_rain)
707 nx_weather_params(NX_WEATHER_STORM, p_storm)
708 if p_storm[NX_WP_PARAM_WIND_SPEED] <= p_rain[NX_WP_PARAM_WIND_SPEED] { return __syscall(93, 60, 0, 0, 0, 0, 0) }
709 if p_storm[NX_WP_PARAM_PRECIP] <= p_rain[NX_WP_PARAM_PRECIP] { return __syscall(93, 61, 0, 0, 0, 0, 0) }
710
711 // T8: FOG has very low visibility + high fog density.
712 let p_fog: *i64 = (sys_mmap(NX_WP_PARAM_COUNT * NX_SIZEOF_NX_INT)) as *i64
713 nx_weather_params(NX_WEATHER_FOG, p_fog)
714 if p_fog[NX_WP_PARAM_VISIBILITY] * 10 >= q * 3 { return __syscall(93, 70, 0, 0, 0, 0, 0) }
715 if p_fog[NX_WP_PARAM_FOG_DENSITY] <= q * 5 / 10 { return __syscall(93, 71, 0, 0, 0, 0, 0) }
716
717 // T9: Combined nx_weather_at writes both kind and params.
718 let kind_out: nx_int = nx_weather_at(42, q, 100 * q, 200 * q, 0, 0, p_fog)
719 if nx_weather_kind_is_valid(kind_out) != 1 { return __syscall(93, 80, 0, 0, 0, 0, 0) }
720 let p_check: *i64 = (sys_mmap(NX_WP_PARAM_COUNT * NX_SIZEOF_NX_INT)) as *i64
721 nx_weather_params(kind_out, p_check)
722 if p_fog[NX_WP_PARAM_PRECIP] != p_check[NX_WP_PARAM_PRECIP] { return __syscall(93, 81, 0, 0, 0, 0, 0) }
723 if p_fog[NX_WP_PARAM_TEMP_OFFSET] != p_check[NX_WP_PARAM_TEMP_OFFSET] { return __syscall(93, 82, 0, 0, 0, 0, 0) }
724
725 // T10: Biome routing -- DESERT should heavily favor CLEAR + HEATWAVE.
726 // Sample many cells; tally counts.
727 var n_clear_desert: nx_int = 0
728 var n_heat_desert: nx_int = 0
729 var n_snow_desert: nx_int = 0
730 var ti: nx_int = 0
731 while ti < 200 {
732 let kd: nx_int = nx_weather_kind_at_biome(99, q, ti * q, ti * q, 0, 0, NX_BIOME_DESERT)
733 if kd == NX_WEATHER_CLEAR { n_clear_desert = n_clear_desert + 1 }
734 if kd == NX_WEATHER_HEATWAVE { n_heat_desert = n_heat_desert + 1 }
735 if kd == NX_WEATHER_SNOW { n_snow_desert = n_snow_desert + 1 }
736 ti = ti + 1
737 }
738 // DESERT distribution: CLEAR 55 + HEATWAVE 22 = 77 of 100 buckets.
739 // From 200 samples we should see at least 100 CLEAR+HEAT total and 0 SNOW.
740 if n_clear_desert + n_heat_desert < 100 { return __syscall(93, 90, 0, 0, 0, 0, 0) }
741 if n_snow_desert != 0 { return __syscall(93, 91, 0, 0, 0, 0, 0) }
742
743 // T11: TUNDRA should produce SNOW + BLIZZARD frequently; no HEATWAVE.
744 var n_snow_tun: nx_int = 0
745 var n_bliz_tun: nx_int = 0
746 var n_heat_tun: nx_int = 0
747 var ti2: nx_int = 0
748 while ti2 < 200 {
749 let kt: nx_int = nx_weather_kind_at_biome(99, q, ti2 * q, ti2 * q, 0, 0, NX_BIOME_TUNDRA)
750 if kt == NX_WEATHER_SNOW { n_snow_tun = n_snow_tun + 1 }
751 if kt == NX_WEATHER_BLIZZARD { n_bliz_tun = n_bliz_tun + 1 }
752 if kt == NX_WEATHER_HEATWAVE { n_heat_tun = n_heat_tun + 1 }
753 ti2 = ti2 + 1
754 }
755 if n_snow_tun + n_bliz_tun < 60 { return __syscall(93, 100, 0, 0, 0, 0, 0) }
756 if n_heat_tun != 0 { return __syscall(93, 101, 0, 0, 0, 0, 0) }
757
758 // T12: Evolution factor envelope.
759 // Phase 0 -> 0; phase ramp_in -> q; phase 0.5 -> q; phase 1 -> 0.
760 if nx_weather_evolution_factor(0, q) != 0 { return __syscall(93, 110, 0, 0, 0, 0, 0) }
761 if nx_weather_evolution_factor(q / 2, q) != q { return __syscall(93, 111, 0, 0, 0, 0, 0) }
762 let mid_phase: nx_int = nx_weather_evolution_factor(q * 15 / 100, q)
763 // At exactly phase=0.15Q the factor reaches q. Allow +/- a few LSB.
764 if mid_phase < q - 2 { return __syscall(93, 112, 0, 0, 0, 0, 0) }
765 // After 0.85 ramps down.
766 let late_phase: nx_int = nx_weather_evolution_factor(q * 92 / 100, q)
767 if late_phase >= q { return __syscall(93, 113, 0, 0, 0, 0, 0) }
768 if late_phase <= 0 { return __syscall(93, 114, 0, 0, 0, 0, 0) }
769
770 // T13: Smooth-params writes well-formed params.
771 let ps: *i64 = (sys_mmap(NX_WP_PARAM_COUNT * NX_SIZEOF_NX_INT)) as *i64
772 nx_weather_params_smooth(42, q, 100 * q, 200 * q, 0, 0, NX_BIOME_TEMPERATE_FOREST, ps)
773 if ps[NX_WP_PARAM_VISIBILITY] < 0 { return __syscall(93, 120, 0, 0, 0, 0, 0) }
774 if ps[NX_WP_PARAM_VISIBILITY] > q { return __syscall(93, 121, 0, 0, 0, 0, 0) }
775
776 // T14: Smooth-params at cell BOUNDARY differs gradually from cell CENTER.
777 // Default cs = 50000. Cell (0,0) center at (25000, 25000).
778 let p_center: *i64 = (sys_mmap(NX_WP_PARAM_COUNT * NX_SIZEOF_NX_INT)) as *i64
779 let p_edge: *i64 = (sys_mmap(NX_WP_PARAM_COUNT * NX_SIZEOF_NX_INT)) as *i64
780 nx_weather_params_smooth(42, q, NX_MAGIC_25000, NX_MAGIC_25000, 0, 0, NX_BIOME_TEMPERATE_FOREST, p_center)
781 nx_weather_params_smooth(42, q, NX_MAGIC_49999, NX_MAGIC_25000, 0, 0, NX_BIOME_TEMPERATE_FOREST, p_edge)
782 // The two are likely different OR equal-by-coincidence. Just
783 // verify nothing crashed and both are in well-formed range.
784 if p_center[NX_WP_PARAM_VISIBILITY] < 0 { return __syscall(93, 130, 0, 0, 0, 0, 0) }
785 if p_edge[NX_WP_PARAM_VISIBILITY] < 0 { return __syscall(93, 131, 0, 0, 0, 0, 0) }
786
787 // T15: Wind direction returns a (roughly) unit-magnitude Q14 vector.
788 let wind_dir: *i64 = (sys_mmap(2 * NX_SIZEOF_NX_INT)) as *i64
789 let speed: nx_int = nx_weather_wind_at(42, q, 100 * q, 200 * q, 0, 0, NX_BIOME_TEMPERATE_FOREST, wind_dir)
790 if speed < 0 { return __syscall(93, 140, 0, 0, 0, 0, 0) }
791 // dx^2 + dy^2 ~ Q^2 (unit-vector in Q14). Allow +/- ~5% due to
792 // discrete compass + table rounding.
793 let mag2: nx_int = (wind_dir[0] * wind_dir[0] + wind_dir[1] * wind_dir[1]) / q
794 if mag2 < q * 90 / 100 { return __syscall(93, 141, 0, 0, 0, 0, 0) }
795 if mag2 > q * 110 / 100 { return __syscall(93, 142, 0, 0, 0, 0, 0) }
796
797 // T16: Lightning -- TUNDRA storm probably has at least some hits
798 // somewhere; CLEAR biome (default temperate) should produce 0 at
799 // most random samples. We verify the basic call doesn't crash and
800 // returns a 0/1.
801 let l0: nx_int = nx_weather_lightning_active_at(
802 42, q, 100 * q, 200 * q, 0, 0, NX_BIOME_TUNDRA, NX_MAGIC_5000, q
803 )
804 if l0 < 0 { return __syscall(93, 150, 0, 0, 0, 0, 0) }
805 if l0 > 1 { return __syscall(93, 151, 0, 0, 0, 0, 0) }
806 // DESERT biome (no STORM/BLIZZARD likely) lightning should be rare
807 // but the predicate must still return 0/1 cleanly.
808 let l1: nx_int = nx_weather_lightning_active_at(
809 42, q, 100 * q, 200 * q, 0, 0, NX_BIOME_DESERT, NX_MAGIC_5000, q
810 )
811 if l1 < 0 { return __syscall(93, 152, 0, 0, 0, 0, 0) }
812 if l1 > 1 { return __syscall(93, 153, 0, 0, 0, 0, 0) }
813
814 return 0
815}