code wiki / (root) / nx_weather_pattern.nx

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}