Commit Graph

351 Commits

Author SHA1 Message Date
chrg6
5f9374e1d9 Keep the fraction of a frame the ocarina recorder's clock was losing above 60 fps, in a new patch set orecord.
The ocarina manager counts the length of each note and rest in whole 60 Hz frames and adds the time manager's frames
per update to that clock at four sites (main+0x32926c8, main+0x3293410, main+0x3296b08, main+0x3296da4) through
fcvtzu, which cuts the step toward zero. Above 60 fps the step is under one frame, so the clock never moved: a
recording kept its notes without their lengths (the recorder leaves a length of 0 out), the 360 and 120 frame limits
never came, and a recording ended after a fixed 60 frames of its timer instead of following the clock; at the 40 and
45 fps limits it counted one a frame. The set replaces each fcvtzu with nine words that add the fraction carried on
the patch page's +264, store what is left after the cut (NaN and negatives as 0) and convert the sum. Steps of one
and two frames give the game's count with the fraction left at 0, so 30 and 60 fps are unchanged. The words call
nothing and take no lock (one of the functions returns through an unsaved x30), and use only v16-v18, which the three
functions never name. The set is appended so that no existing set's index moves; ocarina_timing = 0 gives the game's
words.

Not reproduced before the change (read from the code). The generator's check of the original words passed against
the 1.1.1 main, the new tests/ocarina_timing_check.py passed (.venv, Unicorn), as did frame_step_check,
text_speed_check, sprint_check, movie_timeline_check, cfg_parse_check, launcher_test and cfg_rows_check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 20:13:02 -05:00
chrg6
9770d409d5 Say what NX2NAVI_AUDIO_STRICT, the audio_bad log lines and the docs really do, as the review of the audio change found them overstated.
The review found six texts that claimed more than the code does. NX2NAVI_AUDIO_STRICT=1 does not bring 0.10.1 back
exactly: it also ends the process on a limiter attack or release that is not a number and on a mix in use with a
negative buffer count, which 0.10.1 let through, so the cfg.c comment, nx2navi.ini.example, the audren.h audio_bad
comment and the test's comment now say it does so for most of these values. Its FATAL line described the non-strict
outcome ("its sound passes through unchanged", "the renderer goes on") and then ended the process; it now ends with
"NX2NAVI_AUDIO_STRICT=1 ends the process here instead". The audren.c header and docs/architecture.md said nothing the
game sends ends the process, while OpenAudioRenderer's parameters and the request's buffer descriptors still do; both
now say a value in the game's RequestUpdate does not, and name the two that still do. The first log line of a kind
gave "N such values so far", but bad_count counts every update and every render pass that meets any kind, so it now
reads "N times so far for all kinds together"; the renderer_close line and the bad_count comment say the same.
docs/building.md and the test's comment called 0x5299 the one result the SDK hands back; the SDK hands back 0x5299,
0x5499 and 0x5699 and aborts on any other. Texts only: no behaviour changes.

The first commit's message says any other result than 0x5299 makes the SDK abort; it should read any result but
0x5299, 0x5499 and 0x5699. The branch's history is kept, so this message carries the correction.

audio_params_check (the strict runs still end with "FATAL: audio:" and the switch's name), cfg_rows_check (0
problems) and check_content passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 20:13:02 -05:00
chrg6
7e7fed639e Refuse or leave out the audio values the game sends that the renderer cannot use, instead of ending the game on them.
The audio renderer checked the game's RequestUpdate with about 80 rt_fatal and need() sites in audren.c,
audio_mixer.c, audio_effects.c and audio_reverb.c, so a section size, a splitter or mix record or an effect setting
out of range (a reverb3d whose delays add up past 0.4 s, a limiter attack outside 0 to 1, a mix buffer index past its
mix, a filter coefficient that is not finite) ended the process. The tested route never reached one; dungeons and
bosses were never run. Now an update whose sections do not match the renderer is refused whole with 0x5299 before
anything of it is applied: the SDK's RequestUpdateAudioRenderer hands that result back to the game, which ignores it
and sends its next update, where any other result makes the SDK abort. A record with an id the renderer does not have
is left out of an update that is otherwise applied, a splitter destination id it does not have is stored as -1, which
both render loops skip, and an effect or sink whose values cannot be used is passed through or skipped in the render
pass, with every index checked before its first write. audio_bad logs the first of each kind per renderer with the
game frame and counts the rest. rt_fatal stays for host allocations, OpenAudioRenderer's parameters and the checks
the new ones turn into invariants. Two inputs that did not end the process before change too: a mix in use with a
negative buffer count is refused (it laid the mixes after it out before buffer 0), and a limiter attack or release
that is not a number passes the sound through instead of turning the mix into NaN. NX2NAVI_AUDIO_STRICT=1
(audio_strict, default 0) makes audio_bad end the process as before, for bug reports.

Not reproduced before the change in the game; crafted updates reproduced the fatals. The new
tests/audio_params_check sends 27 refused updates, 10 records and 38 effects and sinks the renderer cannot use
through renderer_handler and runs the strict switch; 15 deliberate breakages of the change each made it fail.
audio_mixer_check, cfg_rows_check, check_content, a compile of every runtime file with no new -Wall -Wextra warnings
in the changed ones and the stability pass's test matrix (the two baseline environment failures only) passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 20:13:02 -05:00
chrg6
51c9261db3 Close a frame's replay state under the submission lock: at the present when the lock is free, else first thing in the next submission, so another thread's replay never sees it reset halfway.
present_image ran the frame's bookkeeping on the presenting thread without a lock: vk_exec_frame_log (the trace counts
and vk_exec_cond_frame_end's frame count, which the conditional draws go by), dynres_frame, vk_exec_barrier_log,
vk_dlaa_frame (the DLSS mode and its LOD bias, the frame's points, the swap image, the idle releases), vk_mvsurvey_frame,
vk_mvprev_frame (which swaps the two motion tables the draws insert into) and vk_gpu_passes_frame. All of it is state the
replay reads and writes as it records, under sub_lock. With the replay on the submitting threads the game presents on
one thread while another may be replaying (the overlap lines of the 0.10.0 fresh session, at area changes), so the
present reset it in the middle of that replay: a DLSS frame reset halfway, the motion tables swapped halfway (with
MVPREV_CHECK=1 an index out of bounds), the conditional draws counted against the next frame.

Now the bookkeeping is frame_end, and runtime/vk_frame_owed.h decides where it runs. present_image marks its frame owed
and runs it itself only when it gets sub_lock at once (vk_sync.c vk_sync_try_locked); otherwise vk_sync.c submit runs
every owed frame's, in frame order, right after it takes the lock and before its replay (vk_present_frames_owed), on
whichever thread submits next, the present thread's blit submission included. The presenting thread never waits for
another thread's replay for it: no new wait on a drawing thread; the thread that takes the lock next does the work it
would have done at the present, tens of microseconds normally. The hand-off, vk_texture_get with its upload flush and
vk_present_drain stay outside the lock, as frame_end must not wait for the present thread or submit; its callees were
checked for that. vk_sampler_aniso_live and vk_mem_census keep their place in present_image (their own locks). In the
asynchronous replay the other holder of sub_lock is the present thread's blit, and the same applies.

NX2NAVI_PRESENT_ORDER (present_order in [diagnostics], default 1, read at start) puts it back at the present with 0, as
in 0.10.1: metas, known and sections rows and an nx2navi.ini.example entry.

Not reproduced before the change. Checks that passed: the new tests/frame_owed_check (one presenting and three
replaying threads, some replays holding the lock 40 ms, over 20,000 frames: every frame's bookkeeping once, in order,
never during a replay and before any replay that took the lock after its present; the longest present under 1 ms with
19,000 presents made during a long hold), cfg_rows_check with 0 problems, pass_cc_check on every runtime file (headers
changed), no new -Wall -Wextra warnings in vk_backend.c, vk_sync.c, vk_exec.c and cfg.c.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 20:13:02 -05:00
chrg6
cde143c5d9 Say what upscaler_idle_wait = 1 brings back and what it does not.
The review of 9071dfc3 found that nx2navi.ini.example, cfg.c's metas comment, the commit message and docs/architecture.md
said the rollback switch works "as up to 0.10.1", which it does not exactly. With 1, every place that lets something go
waits for the device again, C-5's queue-lock hazard with it, but what was let go of still goes through vk_dlaa.c's list
and vk_memory.c's deferred destruction, the motion vectors and the HUD-less picture keep their 8-present gate,
vk_upscaler_settle leaves what the submission being recorded names, stale_sizes waits once per call, and a refused DLSS
creation with features let go of still listed waits and tries again, where 0.10.1 turned DLAA off. Each difference is
the safer side. The ini line now says that the game waits at each change as up to 0.10.1 while what was let go of
still goes once the card is past it; cfg.c, vk_dlaa.c's "what the upscalers let go of" and docs/architecture.md say
the same. Comments and docs only.

Checks that passed: cfg_rows_check (0 problems), upscaler_idle_check, cfg.c and vk_dlaa.c compile with -Wall -Wextra
and no warning, tools/check_content.py --staged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 20:13:02 -05:00
chrg6
22c86f08a0 Let DLSS free what it lets go of once the GPU is past it, instead of waiting for the device on the replaying thread.
vk_dlaa.c waited for the whole device to be idle, with the queue lock held, before it freed a size's images and
feature (free_size: a third scene size, a DLSS model change; stale_sizes), the features release_kept releases before a
create, a reactive-mask snapshot (free_snap), the HUD-less picture of another size (hudless_from), prebloom's images
(pb_images) and what retire_idle gives back. Those run on the thread that replays the game's submissions, and with the
queue lock held the present's flip, the completion thread's hold release and every upload submit waited as well, at
each pause-screen size, reload, window size, dynamic resolution step or mode change; AUDIT F-4-02 measured 16.4 to
44.9 ms for release_kept's wait together with the create that followed.

Now nothing there waits:
- images only the replay uses go to vk_memory.c's deferred destruction, stamped with the submission being recorded;
- a DLSS feature goes into a list (vk_up_defer.h) with the timeline value of the last submission that created or
  evaluated it (Size.feat_fence) and is released on the same thread once the GPU is past that value, which is the
  rule NGX gives for releasing a feature and keeps every NGX call on one thread;
- a size's motion vectors and the HUD-less picture, which frame generation reads at a present, go into that list
  behind a present gate of 8 presents and only then to the deferred destruction; the device's idle never covered a
  present still to come, so this covers more than the wait did;
- the list is taken at the top of every evaluation, at every pass while no evaluation runs (retire_idle), and where
  something is let go of (vk_upscaler_let_go).
A feature creation refused while released features are still in flight waits on the timeline from the host for those
features (vk_upscaler_gpu_wait, no queue touched), takes them and tries once more, with a log line; a full list waits
for the device (vk_upscaler_settle). release_kept's features whose last frame the GPU still runs are now released after
the create that follows, not before it: none of them is evaluated again, and its log line says how many.

NX2NAVI_UPSCALER_IDLE_WAIT (upscaler_idle_wait in [display], default 0, read at start) set to 1 waits for the device
at each of these places as up to 0.10.1, for comparison: rows in cfg.c's known[], metas[] and sections[], and an entry
in nx2navi.ini.example. retire_idle still waits for the device before the FSR contexts it gives back while there are
any; vk_ffx.cpp and vk_ffx4.cpp follow in the next commit.

Not reproduced before the change: no DLSS session log here (C-6 rests on the code and on F-4-02's mark). Every runtime
file compiles, vk_dlaa.c and cfg.c with -Wall -Wextra and no warning; cfg_rows_check reports 0 problems.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 20:13:01 -05:00
chrg6
2e077edc9d Fetch the lazy write-back's ranges without the freeze lock, so a touch of GPU-written memory holds up only the threads that touch that range, with wb_lazy_unlocked = 0 to go back.
A CPU touch of an armed range ran its fetch (vk_memory.c lazy_fetch: a GPU copy after everything handed over, and a wait of up to 10 s) inside the fault handler under frz_lock. Every other fault, thaw, freeze and arm waited for the whole copy meanwhile: the replay's copies of guest memory (mem_copy_readable, vk_memory.c mirror_capture) and its freeze pass, the completion thread's arms, file reads and pool finalizes. A write that lost the race to the fetch went to thaw_fault after it and released every other armed range of its 2 MiB region, a second fetch. mirror_evict and hot_check fetched their victims' ranges under the exclusive pool_lock, which the completion thread's arms and every pool lookup wait for. This is AUDIT F-4-05 (C-12); the design's probe of the unchanged code had an unrelated mem_copy_readable, arm and freeze each wait 299 ms behind a 300 ms fetch, and the losing write 378.8 ms with two fetches.

A range now leaves the armed list into a table of ranges in flight (memory.c "Ranges in flight", lz_fly) in the same lz_lock hold, and stays there, its pages inaccessible, until its bytes are down and its pages read-write again (lazy_fly_run), without frz_lock. A fault on a range in flight waits for that entry alone on lz_lock's condition and runs the access again, the losing write included; a thaw, a mapping change, a release of its buffer, an arm, a freeze and mem_lazy_touches see it. Every store to lzr is made under lz_lock from the list, the entries and the region an arm is protecting (lz_arm_r, which replaces the previous commit's restore). An entry starts under frz_lock and lz_lock and ends under lz_lock alone, so a holder of frz_lock may wait for one: its fetch takes only the queue lock, which no frz_lock taker holds. mem_thaw releases what is armed in its regions before it takes frz_lock (lazy_release_regions), so file reads and pool finalizes fetch without it; a mapping change's thaw still fetches under it, as its page calls follow. The holds a fault sets (lz_hot, lz_hots) are now set as the range goes into flight, so an arm meanwhile finds them. The fetch takes several copies in one submission and one wait (MemLazyCopy), and the back end has four slots of command pool, command buffer and fence, so fetches of different ranges run at once; a release takes up to 16 armed ranges out together.

mirror_evict and hot_check now fetch what only their victims' mirrors hold before taking pool_lock exclusively, and then, under the lock, release only what was armed since (usually nothing) before the mirror leaves the list and the device address table names the imported buffer again. The design moved the release after the lock instead, which would have had the table change before the fetch: a shader of a submission still running reads through the table as it changes, and the fetch waits for every submission handed over. A victim that vk_pool_fini took off the list meanwhile is left to it; hot_check copies its log line's fields under the lock, as its records may be freed once the lock drops.

The new setting wb_lazy_unlocked (NX2NAVI_WB_LAZY_UNLOCKED, default 1, restart) set to 0 keeps frz_lock from the take-out to the end of each flight and leaves mem_thaw's releases to the thaw, as in 0.10.1; the table, the slots and the eviction order stay. The "mem: lazy write-back" line counts segments fetched; a second line gives the touches that waited for a range in flight, the longest such wait and the most ranges in flight at once; "mem: sizes" counts fetches that found every slot busy. The frz_lock and lz_lock comments say what holds what now (B-12). mem_lazy_touches has callers outside memory.c (camera_speed.c, nvn_api_cmd.c ctl_fingerprint), which now skip a range in flight instead of faulting on it and waiting.

Not reproduced in the game before the change. tests\lazy_fetch_check.py now runs 20 scenarios as their own processes: arm races, no stall of freezes, copies, arms, thaws and releases of other ranges during a fetch, joins with frz_lock held elsewhere, a losing write that keeps the region's other range armed, two fetches at once, a thaw, a mapping change and a release meeting a range in flight, an arm refused over one, the foreign give-back, the chunk mode, the old wait with wb_lazy_unlocked = 0 (joins and losing writes as well), the report lines, 80 touching threads past the 64 entries, and a freeze refused; all pass, in six runs. Taking the join before frz_lock out fails the join scenario. cfg_rows_check reports 0 problems; every runtime file compiles, and memory.c and vk_memory.c add no -Wall -Wextra warning; the test matrix passes 75 of 77, installer_test and worktree_test failing for the environment as before. vk_memory.c's slots and the eviction's order have no check without a device.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 20:12:50 -05:00
chrg6
5ee960f811 Add NX2NAVI_GEN_SEED, a test switch that starts the page write generations at a given value, so a play-test can cross 2^32 in seconds instead of 15 to 66 hours.
Whether the 64-bit generations hold past 2^32 in the game, with every texture check, mirror sync and shadow copy of a
real scene, could not be seen in play: reaching 2^32 takes 15 to 66 hours. gen_wrap_check shows it for the generation
code alone. NX2NAVI_GEN_SEED=<n> (nx2navi.ini gen_seed, diagnostics, default 0) makes the run's first generation
n + 1: with 4294966000 a run passes 2^32 within its first seconds, and with 1099511627000 the low 32 bits pass
through 0 again.

The first gen_next, before anything is stamped, reads the value (gen_seed). It is parsed with strtoull, decimal or
0x hexadecimal: strtoul stops at 2^32 - 1 where unsigned long is 32 bits, as on Windows, and would turn a seed past
32 bits into 4294967295 without a word. It is capped at 2^62, far from the 64-bit end, and only ever raises the
counter, since a lower value would leave stamps newer than it that read as written at every check. A value that is
no number, or negative, is logged and ignored; a seed in use is logged as "mem: page generations start at <n>
(NX2NAVI_GEN_SEED, a test switch)". Without the switch the only cost is one compare a generation that is true once.

cfg.c gets its metas row (0, 0..4611686018427387904, read at start), its known[] name and its sections[] entry, each
on a line of its own next to the other memory test switches (STAGING_FAIL_AT, VRAM_BALLAST_MB); nx2navi.ini.example
gets a commented gen_seed entry under [diagnostics]. The settings menu does not show it.

tests/gen_wrap_check runs the generation code a second way: the counter left at 0 and the seed taken from
NX2NAVI_GEN_SEED, for 4294967288, 0xfffffffff8, 0x7ffffffffffffff8 (capped), 12abc, -8, 0 and unset, each with the
start and the log line expected of it, then the same compares across the boundary. With strtoul in place of strtoull
two of those runs fail. Checks that passed: gen_wrap_check, gen_width_check, cfg_rows_check (0 problems),
pass_cc_check on vk_memory.c and cfg.c, no new -Wall -Wextra warnings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 20:12:50 -05:00
chrg6
e0843439a7 Add the reflex_limit setting (NX2NAVI_REFLEX_LIMIT, default 1, live), which gives the frame limit back to the game's pacer with DLSS Frame Generation.
Reflex's hold of the frame limit with DLSS-G is unmeasured here (no RTX 40 or 50), so a player needs a way back to
the pacing of 0.10.1 without an older DLL. vk_dlssg.c limit_allowed reads the setting through cfg_bool, again
whenever a setting changed (cfg_version), as vk_lowlat.c reads low_latency; with 0 the presenting guest thread asks
Reflex for no limit, the present thread hands Streamline 0, and the "frame rate:" line names NX2NAVI_REFLEX_LIMIT=0 as
the reason the game's own limiter holds it. The row sits beside FG_MULT in metas[], beside REFLEX in known[] and
beside LOW_LATENCY in sections[] (display), and nx2navi.ini.example explains it under framegen_multiplier.
cfg_rows_check reports 0 problems; vk_dlssg.c and cfg.c compile clean with the dev build's flags.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 20:12:49 -05:00
chrg6
153295b64c Check cfg_meta's result before reading a row's default in default_number and gfx_cfg_window_size.
cfg.c default_number, which cfg_int and cfg_double use for a set value that does not parse, read cfg_meta(name)->def
without a check, so a key without a row whose value was set and unparsable crashed there (the verifier's probe: a
segmentation fault inside default_number). gfx_present.c gfx_cfg_window_size did the same for WINDOW_SIZE. Neither
is reachable today: every unguarded value read has a row (tests/cfg_rows_check.py holds it so), the three guarded
ones use cfg_bool, and WINDOW_SIZE has a row.

default_number gives 0 for a key without a row; gfx_cfg_window_size falls back to 1920x1080 when the row or its
default is missing. Taking the device profile's default there (1280x800 on a Steam Deck) would change what players
get and is left for approval.

Reproduced before the change with the new case in tests/cfg_parse_check.py (cfg_int and cfg_double of an unset row's
key set to "abc" crashed the program); with the change both give 0 and the check passes, as do the cfg rows checks
and the compile checks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 18:42:34 -05:00
chrg6
5b9ed9ed12 Say so in the log when the settings table is full and a key or a menu change is not kept.
cfg.c keeps at most CFG_MAX_KEYS (512) keys. add, cfg_set, cfg_reset and migrate stopped silently at the cap, so a
file with more keys (every cheat ever switched keeps a cheat_<hash> line, unknown keys count too) had its later keys
ignored and a later menu change lost, with nothing in the log (the audit's probe: low_latency after 600 cheat lines
read as unset, a cfg_set of vk_present lost, 0 log lines). Only a file far past what players have reaches it.

A line now says so: at load once per load, naming the first key and line not read, and once a run for the changes
cfg_set, cfg_reset and migrate could not keep. Raising the cap would change which keys take effect and is left for
approval.

Not reproduced before the change by a game run; tests/cfg_parse_check.py gains the case: a file of 600 cheat keys
and then low_latency gives one line at load from line 513 on, and two cfg_set past the full table one line. The cfg
rows checks and the compile checks pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 18:42:34 -05:00
chrg6
b4556957c2 Add a hang watch that logs a report when no frame is shown with the window in front, or the GPU does not finish, for 20 s.
A present, a submission or a completion that never came back (a deadlock, a driver wait that does not return, a
GPU hang without a reset) left a log that just stopped: kernel.c wait_watch covers guest lock and thread-end waits
only and is off by default, and nothing watched the present, submit or completion threads.

hang_watch.c runs a thread of its own in a game run (main.c starts it before the boot; never in the tool modes).
Once a second it reads, without a lock, gfx_counters.frames and the submissions issued and done (the new
vk_sync.c vk_sync_progress, no device call). It reports a stall of the frames while the guest runs, the window is
in front and not minimized (gfx_window_in_front) and no orderly end has begun (rt_exit_started): the game stops
presenting without the focus, so a focus change starts the count again, and the boot screen is not judged. It
reports submissions issued and not done, "done" unchanged for N s, whatever the focus. One report per stall, a
new stall needs progress, at most 3 a run. A report names the stall with the counts, the focus and the world
clock; who holds the present, queue and window-start locks (gfx_present.c gfx_lock_holders, read from
PlatRMutex.owner and depth); the guest threads' lock and thread-end waits (kernel.c kern_wait_report, wait_watch's
walk factored out as wait_pass, run with limit 0 under ten 10 ms trylocks of the kernel lock); and then writes the
log out with logq_flush, never rt_log_sync, which would put every later line on the thread that logs it in a run
that may recover. It never ends the process and adds no wait to a drawing thread. The per-thread wait sites and
lock owner wrappers are left for a later change.

NX2NAVI_HANG_WATCH_S sets the seconds (default 20, 0..600, read at start, 0 off): a metas row, a known[] name, a
diagnostics sections[] entry and an nx2navi.ini.example entry.

Not reproduced before the change (a diagnostic). The new tests/hang_watch_check.py drives hang_watch.c's decisions
on made-up readings: no report while things move, unfocused, minimized or ending; one per stall of the frames with
the window in front and one again after progress; one for submissions not done whatever the focus; none for an
idle GPU; at most 3; logq_flush called and logq_sync never. tests/cfg_rows_check.py (0 problems), the scratchpad
rows check ("missing []") and the compile checks of every runtime file pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 18:42:34 -05:00
chrg6
0fbd2b8f31 Add a static check of cfg.c's settings tables to the content guard, and list the 15 names it found missing from known[].
A // on a metas[] line hides the rows after it on that line, and the game then ends at start with "FATAL: cfg:
NX2NAVI_<X> has no row in the settings table"; that shipped in two test builds and nothing caught it before a
start. And 15 names the runtime reads were not in known[], so setting any of them in nx2navi.ini logged "unknown
key" and put it in the menu's list of unknown keys although it took effect: AUDIO_DUMP, DEFER_STAMP, DEPTH_TWIN,
DLAA_ZFIT, DUMP_SKIP_RUNS, DUMP_TEX, DUMP_UBO, EVENT_ORDER, GATHER_BIAS, PIN_SWEEP, REC_CHECK, REC_DELAY_US,
REG_ORDER, SHADOW and ZERO_LOCALS (GATHER_BIAS and ZERO_LOCALS through vk_caps.c env_u32).

tests/cfg_rows_check.py (standard library only) parses known[], metas[], sections[], DECK[] and LOWEND[] by
brace matching that skips string and character literals and comments (an apostrophe in a comment inside a table
would otherwise open a character literal and run the table on past its end), line by line with the first // outside
a string split off, and the first #if branch standing for the table. It fails on a row or name after //, a /* in a table, a name twice in known[], two metas[] rows for a
key, a metas[] row not in known[], a cfg_value/cfg_int/cfg_bool/cfg_double read (directly or through one level of
wrapper) with no row and no cfg_get, cfg_is_set or cfg_meta guard earlier in the same function (kernel.c SPIN and
SVC_WAITS, vk_headroom.c VRAM_HEADROOM pass that way), and a name read through cfg_get, cfg_is_set, cfg_meta,
rt_knob, a cfg_get or "NX2NAVI_%s" wrapper, or a patches-v111.txt set line that is not in known[] or a cheat.
--self-test puts each kind of fault into a copy and checks it is caught. The content guard workflow runs the
check after tools/check_content.py. The 15 names go into known[] on a line of their own with no //.

Reproduced before the change: the check reported the 15 names on stability-pass, as the audit's prototype did.
Now it reports 0 problems and its self-test catches all 15 injected faults; the scratchpad cfg rows check
("missing []"), cfg_parse_check and the compile checks pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 18:42:10 -05:00
chrg6
8c1ec42406 Log the nx2navi.ini and environment values that are outside their settings' sets, with the line they came from.
cfg.c takes a value as written and logged nothing about it: "pace_smooth = 0 ; off" keeps the comment in the value
and cfg_bool reads it as on, "fullscreen = false" is on too, and "sharpness = 250" is past 0..100 (the audit's
probe on cfg.c printed exactly that, with 0 log lines during the load). A player who edits the file by hand had no
way to see why a setting did not take.

After cfg_load (and after migrate, so a value it rewrites is checked as read) each file value is checked against
its row: a 0|1 switch with any other value, a value that is not a number in a lo..hi range, and a word that is
none of a list of words, case aside. A list with a number in it is left alone: it is the menu's presets and its
consumer takes more (fps_limit any rate from 30 to 240). A list of words is reported as "none of the menu's
choices" only, since a consumer may take more (dlss_preset a..m). A value holding " ;" or " #" is noted on any row:
the comment is part of it. cfg_init checks the environment's NX2NAVI_ values the same way. At most 16 lines a load,
then a count. The values read are unchanged; reading words for off or cutting comments (F-8) needs approval.

Not reproduced before the change by a game run; the new tests/cfg_parse_check.py builds cfg.c with stand-ins and
checks each kind of line with its line number, the environment's, the cap of 16 and a count, and no line for a file
whose values fit (including fps_limit 144, render_scale 300, dlss_preset k, pad_layout xbox_prompts). Every value
nx2navi.ini.example shows passes without a line. launcher_test PlayerIniTest, the cfg rows check and the compile
checks pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 18:42:10 -05:00
chrg6
811024e69b Count the recording copies whose control memory the game reused, behind the new NX2NAVI_COPY_RETIRE switch.
Recording copies are retired only when the game adds their control memory again (release_copies), and the fresh
0.10.0 session's "mem: sizes" lines show them growing for good: 220 at frame 600, 223,646 (56.3 MiB) at frame 39600,
with no zombies. This is the first stage of retiring them: it only counts, so the second stage can be judged on
numbers. With copy_retire = 1, copy_add queues each copy's control position, and an "nvn copy sweep" thread at the
lowest priority fingerprints the 16 bytes there within 5 seconds, then visits up to 4096 copies every 5 seconds from a
cursor and counts those no submission used for 5 minutes whose bytes changed or whose memory is no longer mapped. The
"mem: sizes" line gains "of those idle, control memory changed" and "control memory gone". Nothing is freed.

Our driver never writes control memory, so the bytes compared are the game's own leftovers; a reuse that writes the
same bytes is not seen and the counts are a lower bound. Every read of guest memory happens on the sweep thread, never
on a recording thread: a lazily armed range is not read at all, an untouched lazy-heap page is taken as zeros, and the
read holds the mapping with mem_read_hold. The sweep takes copy_lock shared only, 64 lookups at a time, and its queue
has a lock of its own that is never held with copy_lock. Copies that share a control position (EndRecording falls
back to the block's start when it is full) each keep their own record.

The age ring behind "of them unused for 5 minutes" grows from 1024 to 65536 minutes: at 1024 a copy last used 17 hours
earlier shared its slot with the minute now and counted as recent.

New row COPY_RETIRE (0|1, default 0, restart) in known[], sections[] (diagnostics) and metas[], and a copy_retire entry
in nx2navi.ini.example. tests/copy_sweep_check.py cuts the fingerprint, visit, queue and pass functions from
nvn_api_cmd.c with stub copies and stub guest memory: changed bytes count as changed, unmapped memory as gone,
unchanged bytes and copies used within 5 minutes count nothing, armed ranges and untouched pages are never read, retired
and replaced copies leave the counts, and two passes over 5000 copies visit each once or twice. The cfg row check
reports no missing rows, launcher_test passes, and the compile check adds no warnings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 18:41:46 -05:00
chrg6
26b7cfd658 Put the process's OS handle count on the sizes line and add a handle census by kind behind NX2NAVI_HANDLE_CENSUS.
A session of 2026-11-13 grew from 683 to 1095 OS handles in 11 minutes, rising at every area load and never falling,
while the guest's own handles stayed at 116-122 and the threads flat; the VMA block count fell back while the handle
count did not. The log could not show it: the 600-frame "mem: sizes" line had only the guest's handles, and nothing
told which kind of handle grows.

kernel.c kern_sizes now adds "process handles" (plat_handle_count, one system call every 600 frames under trace=perf)
beside the guest's "handles". platform.h gets plat_handle_census: on Windows the system's extended handle table
filtered by our process id, with the type names from NtQueryObject's ObjectTypesInformation; on Linux the
/proc/self/fd links grouped by kind. It never opens, duplicates or names a handle (a name query can block on a pipe).
With NX2NAVI_HANDLE_CENSUS=1 (default 0, read at start) and trace=perf, perf.c starts one long-lived thread that the
600-frame report wakes; when the count grew by 16 or more since its last census it logs "mem: handles frame N: T in
all (+D since the last census): Event a (+x), ...". The census reads a few MiB of the system's table in 2-5 ms, off
the drawing threads; one thread for the run keeps a new thread's own handle out of what it counts. New cfg rows:
HANDLE_CENSUS in known[], sections[] (diagnostics) and metas[] ("0", "0|1", restart), and an nx2navi.ini.example
entry.

New tests/handle_census_check.py builds the platform layer alone: 20 events and 5 sections show as Event +20 and
Section +5, the total stays within 2 of GetProcessHandleCount, the figures return once they are closed, and kinds
past the room given go into the last entry; docs/building.md describes it. Not reproduced before the change (the
growth needs a game run). handle_census_check, sync_wake_check and the cfg row check pass; every runtime file compiles
with the dev flags, and perf.c, kernel.c, win32.c and cfg.c add no -Wall -Wextra warnings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 18:41:39 -05:00
chrg6
1fa8b61b87 Give back the ASTC decoder's grown scratch 5 s after its last large texture, not at the next upload.
A scratch grown past 32 MiB for a large texture went back to the usual size only when the next ASTC upload asked for one, so in the 2026-11-13 session the decoder held 224 MiB for the 70 s after the first area load and from a later load to the session's end. Each present now gives it back once no texture needed more for 5 s; nothing is made then, the next request makes the usual scratch, and the buffer is freed once the upload batches recorded so far completed, as before. The present only tries the decoder's lock, which can be held while VMA makes a block. astc_scratch_trim = 0 keeps the scratch until the next upload.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 15:57:52 -05:00
chrg6
2969005edc Past the hard count of released textures, drop the cheapest ones first, in one walk per batch.
Past RELEASED_HARD (16,384) the sweep dropped the first released entry in release order that was overwritten or unsampled for 2 s, render targets included, whose drop vk_texture.c already ties to a black frame, and it walked the list from its head for every drop. At the end of the 2026-11-13 session the count sat at 16,384 for a minute while 12,811 of 15,734 released entries owned no memory. The entries now go by class: overwritten ones, then ones that come back intact and are past their usual take-back gap, then other intact ones, and only then ones whose content only the GPU made; still only unsampled ones, and the one sampled longest ago when all were sampled lately, as before. One walk takes a batch down to 256 under the bound. tex_hard_order = 0 keeps the rule of 0.10.1.

The drops past the hard count get a reason of their own, so the texture origins line and the reuse line count them apart from the sweep's drops past RELEASED_MAX, with the classes they came from. The class rules are in vk_tex_release.h and checked by tests/tex_release_check.py.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 15:57:52 -05:00
chrg6
0e0cfe0827 Keep the freezes of idle regions back after slow ones and around area loads.
After a slow freeze the passes wait 250 ms, doubled for each further slow one, but the first fast freeze after a hold started the doubling over, so in the seconds after a load a 4-5 ms freeze came every 250 ms (Kakariko, 2026-11-13: five in a row, 16 frames apart), and freezes of 4-10 ms pushed frames of 25-52 ms over the stutter line. The doubling now starts over only after freeze_quiet_ms (default 5000; 0: the old rule) without a slow freeze. No pass runs while the texture pool makes an area's textures, when the frames are held anyway and one freeze took 32.4 ms, nor for freeze_load_hold_ms (default 3000; 0: off) after that or after an area's load. The regions that go idle meanwhile stay queued and are frozen later; their write-watch queries go on until then. A region is now queued once instead of again every freeze_idle submissions while the passes are held.

The rules are in runtime/freeze_hold.h, checked by tests/freeze_hold_check.py.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 15:57:52 -05:00
chrg6
46d71da8aa Draw task and mesh programs through the mesh fallback by default on AMD's Windows driver for RDNA 1 and 2, where native mesh pipelines lost the device in play; mesh_fallback = 0 draws them natively again.
Every RX 6000 loss whose pass marks named a pass was in one that starts with a native mesh draw, on every version from 0.6.0 to 0.9.2 and with every other setting tried each way; RDNA 1 cards on the same driver draw through the fallback and RDNA 3 and 4 on AMD's other driver draw natively, and neither has such a report. That the native mesh path is the cause is not shown. The decision moves into vk_mesh_path.h with a test of each device kind and setting; has_mesh_shader keeps saying what the hardware has, so the recorder thread and the low-end card test do not change. On those cards no native mesh pipeline is built any more: the seed list and the boot prewarm skip them. The early tail stays off with the fallback, a combination never measured. The fatal line names the mesh path, and NX2NAVI_VK_NOOPT's default applies with mesh_fallback = 0 alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 12:54:24 -05:00
chrg6
a84a3ab12d Make the release 0.10.1: every setting under the Game tab's Testing heading is off by default again, CPU helper threads (replay_workers = 0) and Start next frame early (tail_early = 0) among them.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 10:49:01 -05:00
chrg6
26d26babe5 Bring public main's bug capture on F8 and its removal of the Steam co-op into the 0.10.0 line.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

# Conflicts:
#	docs/architecture.md
#	launcher/steamlib.py
#	launcher/ui/app.py
#	runtime/boot.c
#	runtime/cfg.c
#	runtime/flight.c
#	runtime/flight.h
#	runtime/gfx_present.c
#	tests/launcher_test.py
2026-11-13 09:52:43 -05:00
chrg6
8a99af1080 Give back the graphics pipelines no draw has used in this run while video memory is short: over tex_vram_pressure percent of the budget, the autosave thread takes up to 256 of them out of the table every 5 seconds and destroys them once the GPU is past the submission being recorded; a draw that needs one later builds it again from the pipeline cache. Mesh pipelines, entries building or linking and compute pipelines stay, and the next pass logs the video memory in use before and after. pipe_release = 0 keeps them all.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 09:19:50 -05:00
epicmeme546
f5da269add Remove the experimental co-op through Steam.
This takes back the three commits that added it (cd2f9f1, e60677e, a01910d): its runtime layer and Steam binding, the Co-op tab and what it drew over the game, the launcher's copy of Steam's library file, its settings, tests and docs. The tree is as it was before them, with the bug capture that came after kept: its note on screen no longer makes room for co-op's lines, and dbg_area, which the co-op had brought and the capture uses, stays.

The four settings of that co-op join the retired names, so a settings file that still has them logs no unknown key. A steam_api64.dll that the launcher put beside the game is left where it is: nothing loads it any more.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-13 09:08:40 -05:00
chrg6
e4df494048 Turn the early tail on by default (tail_early = 1): the game starts its next frame while the graphics card runs the last one's upscaler and interface, and a frame whose end hands the game anything else runs as before; tail_early = 0 turns it off.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 08:58:03 -05:00
chrg6
5faa1687f4 Add quad_index, which draws an indexed quad list as two triangles per quad and closes an indexed line loop, from the game's own indices, and log the first such draw either way.
Vulkan has no quad or line-loop topology. Draws without indices were rewritten already; an indexed quad list went to the GPU with its indices under the triangle list topology, three at a time, joining corners of neighbouring quads, and an indexed loop lacked its closing line. Off by default and read at start; no log of Ocarina has shown such a draw so far, and the new line says when one comes. tests/index_rewrite_check.py checks the index lists.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 08:36:46 -05:00
chrg6
9d5f16bda4 Add oq_hold, which makes a samples-passed report whose draws the full occlusion query ring left uncounted repeat its record's last complete count, and log how often such reports happen.
A draw that finds every query slot still waiting for the completion thread goes uncounted, and the report after it said only what the other draws counted, at least 1: the game takes the object it tested for all but hidden for that frame. With the setting the report writes the larger of that and the last complete count reported into the same record. Off by default and read at start; the count of such reports is logged at the 1st, 10th, 100th and so on either way. tests/oq_hold_check.py checks the rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 08:36:46 -05:00
chrg6
cc5c8f87ad Add shadow_pcf, which makes every depth-compare sampler filter linearly so each shadow test blends the 2x2 shadow map texels around it, and log the filters of the compare samplers made.
Through a nearest filter each of the game's shadow compares tests one texel, so shadow edges are stair steps of shadow map texels that crawl over the ground as the sun cascades follow the camera. The setting is off by default and read at start; with shadow_scale above 1 the samplers were already linear.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 08:36:46 -05:00
chrg6
fadd4a6d5e Copy large textures out of the game's memory on several threads at once: a level of 1 MiB or more goes to a small worker pool as bands of rows (par_jobs.c, tex_deswizzle.c tex_bl_copy_mt), and so does a large block copy for the GPU's ASTC decoder, so the upload's storage copy no longer runs on the drawing thread alone. The bytes are those of the one-thread copy; par_jobs = 0 turns it off.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 08:35:42 -05:00
chrg6
0c153c124e Add mem_budget: one place that says how much room the card and the system have.
A thread samples every two seconds the video memory budget against this process's use (vk_mem_vram) and the system's
commit headroom and free physical memory (plat_commit_info, plat_mem_info), and turns each side into a level: roomy,
normal, tight or critical, with five points of hysteresis upward and a 256 MiB floor. Changes are logged, and the
memory report's system line ends with both levels. MEM_BUDGET_TEST_VRAM_MB and MEM_BUDGET_TEST_RAM_MB pretend a side
is that small, so that what gives memory back can be tried on a large machine; the texture cache's rules are unchanged.
tests/mem_budget_check.py checks the levels and the hysteresis.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 07:55:56 -05:00
chrg6
3573a96f46 Keep the SPIR-V of shaders nobody read for 30 seconds compressed in memory.
The shader memory cache held 777 MiB of SPIR-V for 7115 shaders for the whole session. A background thread now
compresses the SPIR-V of each shader no reader pinned for shader_compress_idle_s seconds (zstd level 1, 26% of the
bytes on the test install's 44,151 modules) and frees the plain copy; readers pin it through gfx_shader_spirv_pin and
get it decoded back. What the drawing thread compares at pipeline misses and draws, the module's FNV-1a and its Binding
decorations, is kept with the shader, so module_binding_missing, the water corrections and the DLSS, crumbs and skip
lists decode nothing. A compute pipeline created at a dispatch and a mesh shader's first pipeline key still decode on
that thread, and the memory report counts those decodes apart, with the SPIR-V as held against uncompressed and the
shaders held compressed. shader_compress = 0 keeps it plain.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 07:55:56 -05:00
chrg6
97671e8ea7 Add the stutter map: each hitch of a play session with its area, what the frame waited for and the pipelines and shaders its draws met missing, and every 10 minutes and at exit the areas ranked by their hitches and the pipelines that cost the most (stutter_map = 1, off by default). tools/stutter_map.py turns a log into a report and counts what a game folder recorded that a pipeline list lacks.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 07:31:57 -05:00
chrg6
cae09a2bcd Add floor_watch: hardware watchpoints on the floor the scan finds log which game code reads and writes it, at 60 fps and again at 120 fps or more.
floor_watch.c takes up to twelve of the floor scan's survivors (the platform's rigid body controller unit first, then one per object at the pivot's height), watches them four at a time through the debug registers on every thread (platform.h plat_watch_set, Windows only), counts each access's call chain in a lock-free table and logs the chains with the executable's symbol names after each pass. prof_syms gains a callback form for that.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 07:08:49 -05:00
chrg6
c84724da2c Make a moving floor carry Link above 100 fps as it does at 60 (set ofollow, floor_carry = 1).
The character controller's three follow-surface modules kept only a tenth of the floor's velocity whenever the physics' inverse step passed 100, which every update above 100 fps did. The gate now compares with 100 over frames per update from the patch page above 60 fps, and with the game's 100 at 60 and 30 fps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 07:08:49 -05:00
chrg6
a74503cd86 Add physics_steps = 2: at least a 60 fps frame's solver steps in every update on 1.1.1.
Link is carried by the Deku Tree's floating platform fully at 60 fps, about a tenth at 160 and not at all at 240,
which follows the solver steps an update (4, 2, 1 with physics_steps = 1) rather than those a second. The new set
osolverfull raises the rounded-up count to the 60 Hz frame's 4 in every update; physics_steps = 2 switches it on and
osolver off at start, and the mode is logged. 60 and 30 fps keep the game's 4 and 8. It costs more physics time above
60 fps: 576 solver steps a second at 144 fps and 960 at 240, against 288 and 240 with 1, the default, which stays
until a run confirms the carry. frame_step_check runs the lifted words of all three counts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 06:26:39 -05:00
chrg6
b912d72b87 Remove the Video memory use row; its low behaviour is the only one now.
Released textures go once they have gone unused for the hold, and what a switched-off upscaler or SMAA held is freed, with no setting to keep them. A vram_use line left in a settings file is known and read by nothing. Memory use is to be decided by the card's and the system's budget instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 06:23:01 -05:00
epicmeme546
4fd70e398f Make F8 write a bug capture and add a test run that starts from one.
F8 now always writes a folder under logs/captures: the area, Link's position and rotation, the camera of the frame on screen and the game's own, the presented frame as a PNG, frame times around the press, the machine, the settings that differ from the defaults, the end of the log, the save, and the game's flags and texts. A note on screen names the capture. The flight recorder's F8 dump goes into the capture's folder.

NX2NAVI_CAPTURE_REPLAY starts a run on a capture's settings, teleports to its area, walks Link to where he stood and puts the free camera where the camera was; tools/capture_run.py prepares the profile from the capture's save, runs it and compares the two captures. docs/bug-capture.md has the files and the steps.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-13 06:22:18 -05:00
chrg6
0e76baad36 Limit replay_small to 0..63, where it can match a group.
A group too small to share out has fewer than 64 draws (2 x CHUNK_DRAWS), so a threshold of 64 or more never matched
in group_start and the setting did nothing. The setting's range is now 0..63, the menu's slider ends at 60 in steps of
4, and a value of 64 or more from nx2navi.ini reads as 63 with one line in the log.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 06:06:27 -05:00
chrg6
175033d49a Merge main's co-op switch name into the 0.10.0 line.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

# Conflicts:
#	runtime/cfg.c
2026-11-13 05:58:46 -05:00
chrg6
328554e7c6 Run the sound manager's job at 60 Hz with whole steps above 60 fps.
The job (main+0x415300) hands the time manager's step to the sound system in its phase 0 and runs the sound objects'
and the system's updates in phases 1 and 2. With the set ostep that step is a fraction of a frame above 60 fps, and fed
fractions the game has been reported to keep sounds held after an area unloaded until no voice was free, new sounds
and then the music going silent until a restart. The new set osound (sound_steps, on by default) calls the page's
+0xf10 after the job's prologue: phase 0 adds the step to what the job is owed and runs once a whole step has built
up, phases 1 and 2 follow that choice, and a job that does not run gets phase 3, which returns through its own
epilogue. After phase 0's load of the step the page's +0xf00 makes a fraction one whole step. At 60 fps and below
the job runs every frame with the game's step. Both sites were checked against the module; frame_step_check covers
the gate at 60, 30, 144 and 240 fps. Needs the game code generated again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 05:54:58 -05:00
epicmeme546
a01910d905 Give the new co-op's switch a name of its own, so an old "coop = 1" turns nothing on.
The Steam co-op took the setting name of the old beta's switch, coop. A player whose nx2navi.ini still had coop = 1 from that beta got the new co-op switched on at the first start of the new build, unasked: the game signed in to Steam as its test app. The switch is now coop_steam (NX2NAVI_COOP_STEAM), off by default, and coop is a retired key like the old co-op's other settings: known, read by nothing.

--probe-cfg now loads a file of the old co-op and checks that its keys are no unknown keys, that coop = 1 leaves the new co-op off, and that coop_steam = 1 turns it on; it passes on a build of this commit. The game itself was not started with the change.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-13 05:30:56 -05:00
chrg6
a7e79b3460 Bring the line's floor scan into the co-op branch.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 04:54:51 -05:00
chrg6
27dae195ab Arm the floor scan only on a sideways drift and keep what lies below Link, logged once a second.
Scan run 1 armed on a floor that only sinks and found points 3.4 m above Link that follow him (the camera's). The scan
now arms on a straight horizontal drift at a steady height, snapshots from 4 m below Link to 0.5 m above, keeps the
triplets that moved with him and lie more than 0.3 m below him or more than 0.5 m from his feet, and logs each of them
(64 at most) once a second with its move over that second.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 04:45:52 -05:00
chrg6
da65aa6099 Bring public main's Steam co-op, the removal of the old co-op and the launcher's Steam library file into the 0.10.0 line.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 04:37:01 -05:00
chrg6
2970b0c7d6 Add a floor scan that finds the floor carrying Link in guest memory on 1.1.1.
The Deku Tree's floating platform is not one of the engine's ground groups, and where its position lives is not known.
With floor_scan set (off by default), once Link has been carried at 60 fps for 30 frames with the left stick at rest,
one snapshot of the game's heap and alias regions keeps every float triplet near him; 30 frames later the triplets
that moved as far as he did are kept, and from then on each frame logs the twelve of them farthest from him with their
addresses, positions and moves. The snapshot logs how long it took.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 04:36:29 -05:00
epicmeme546
cd2f9f12be Add an experimental co-op through Steam: rooms, invites, names in the world, chat and shared progress.
Up to four players, each in their own game and save, meet in a room. Rooms are Steam lobbies and the games talk through Steam's networking, so nobody forwards a port and nobody runs a server. The game signs in to Steam as its test app (480), so Steam shows the player as playing Spacewar. Off by default; the menu has a Co-op tab with Create a room, Join a room by its ID, Invite a friend, the players, chat and Leave, in English, Russian, Brazilian Portuguese and Simplified Chinese.

runtime/steam.c loads steam_api64.dll from beside the exe at run time and declares the C functions it calls itself; nothing of the Steamworks SDK is in the repository, and a player copies the file from a Steam game they have. runtime/coop.c is the room: the lobby's owner is the host, lets games of the same protocol and game build in and lists them, and every game takes positions, chat and progress only from the players on that list. runtime/coop_game.c reads Link from the Player actor and shares progress, items, songs and quest items through the game's own flag routines, each value only ever raised; small keys, rupees, ammunition and hearts stay each player's own, and the save is copied to coop-backup before anything of a room reaches it. The other players show as a name with a distance where their Link stands, projected with the upscaler's camera.

One Steam account cannot be in a lobby twice, and this PC has not the memory for two games, so the room and the game side were run with one game and tests/coop_peer.py as the second player over runtime/coop_local.c, a stand-in for Steam on 127.0.0.1 that only the environment turns on: the peer was let in, saw the game's Link in Hyrule Field, chat went both ways, the save was copied, one flag the save lacked was set and a flag the game does not share was refused. On Steam itself one game signed in as app 480, made a room, showed its ID and the player's Steam name on the Co-op tab and left. Not run: two Steam accounts in one room, an invite, a join by room ID over Steam, a host that leaves, a removal, and the menu's text probe on this build.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-13 04:16:17 -05:00
chrg6
a9f161579b Add a floor probe that logs Link on a moving floor each frame on 1.1.1.
Above 60 fps Link stands still on the Deku Tree's floating platform while it moves under him, and the code that carries
him is not found yet. With floor_probe set (off by default), each present logs the frame's step, Link's position and
how far he moved, and the nearest moving ground group of the engine's ground manager with how far it moved, so one run
at 60 and one above it show where the carry is lost.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 04:04:05 -05:00
chrg6
750034b7fd Run the water and floor surface simulations at their own 60 steps a second above 60 fps on 1.1.1.
The physics' two water managers and the game's interactive floor simulation (the Deku Tree's ground webs) step their
surfaces in fixed steps of 1/60 s, as many whole steps as the frame's time holds, and drop the fraction, so every
frame shorter than 1/60 s ran none and the surfaces stood still above 60 fps. The new set owater (water_steps, on by
default) calls the page between the division and the game's cut, which keeps the fraction per object; at 60 fps the
count is the game's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 03:42:40 -05:00
epicmeme546
c84c035939 Remove the co-op of 0.8.0: its runtime layer, relay, menu tab, settings, tests and docs.
The hidden beta shared story progress through a relay a player had to run and never showed the other players. A new co-op replaces it. Gone are runtime/coop*.c and their headers, the menu's Co-op tab and its texts in four languages, tools/coop_relay.py, the co-op tests, docs/coop.md, the [coop] section of nx2navi.ini.example, the --probe-coop mode, the coop trace item, and Monocypher, which only this layer used.

The old settings stay known as retired keys, so an nx2navi.ini that still sets them logs no unknown key, and they stay on the list of private settings, so a bug report keeps a room's secret, a relay's address and a name out. The platform layer's UDP sockets and fs_save_snapshot stay for the new co-op; cfg_ini_now and fs_profile_path, which nothing else called, are removed.

Checked: the game builds and starts, the menu shows its four tabs, --probe-cfg and --probe-fs pass, and the launcher's private-settings test passes. tests/save_share_check.py builds without its co-op stub; it cannot run from this worktree because the folder's path is longer than Windows allows, on main too.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-13 03:37:26 -05:00
chrg6
6a3c4d6ca2 Give replay_pipe its own row in the settings table again.
The comment added to the replay_workers row swallowed the replay_pipe row behind it on the same line, so every start stopped with "NX2NAVI_REPLAY_PIPE has no row in the settings table".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 03:05:00 -05:00