From 3e868a42fb1615e6f1ad6ad161c3f5ff89b2f645 Mon Sep 17 00:00:00 2001 From: Shinobu2 Date: Sun, 4 Oct 2026 13:57:55 +0000 Subject: [PATCH 001/306] [coop] a newer prop save record always replaces the parked one A record applied at once left its parked predecessor behind, and Drive or a birth then landed that older record on top: a disc's content rolled back. Every land now drops the parked row for its Key first, the host's take of a client intent goes through the same LandRecord, and a parked apply runs on a copy and removes its row only if the row is still that record, so a newer one parked during loadData survives. Evidence: the file built against engine stubs under ASan/UBSan; before, an older parked record replaced a newer applied one on both roles and a re-entrant land during Drive was a use-after-free; after, all six cases pass. Not yet built for Windows or run with two peers. Co-Authored-By: Claude Opus 5.5 (cherry picked from commit 9863f6ba0e8714c73cd32b7eaa54a03e6aae711d) --- docs/props.md | 4 +- .../src/coop/props/prop_save_data.cpp | 81 ++++++++++++------- 2 files changed, 56 insertions(+), 29 deletions(-) diff --git a/docs/props.md b/docs/props.md index b9837d790..6f245930e 100644 --- a/docs/props.md +++ b/docs/props.md @@ -39,7 +39,9 @@ produces, addressed by key (`coop/props/prop_save_data`). Which classes those ar keep: it is whether the class declares a `getData` of its own, read from the live class chain, so a save-backed class the game adds is carried without a change here. A record that arrives for a prop this peer has not created yet waits under its key until that prop appears, because a key survives -the destroy-and-recreate that made the record need to travel in the first place. +the destroy-and-recreate that made the record need to travel in the first place. A newer record +for the same key replaces the waiting one whether it applies at once or waits too, so an older +record never lands on top of a newer one. On the host, a prop born during play -- a device's eject, a drop, a place -- gets its record from the finish-spawning drain a tick after the birth, because the Blueprint that spawned it fills its diff --git a/src/votv-coop/src/coop/props/prop_save_data.cpp b/src/votv-coop/src/coop/props/prop_save_data.cpp index ecb3678d1..b92e54943 100644 --- a/src/votv-coop/src/coop/props/prop_save_data.cpp +++ b/src/votv-coop/src/coop/props/prop_save_data.cpp @@ -220,9 +220,23 @@ void Unpark(std::map::iterator it) { g_parked.erase(it); } +// A newer record for a Key supersedes whatever waits under it, whether the newer one is applied at +// once or parked: a predecessor left behind would be landed later by Drive or a birth, on top of it. +void DropParked(const std::wstring& key) { + auto it = g_parked.find(key); + if (it != g_parked.end()) Unpark(it); +} + +// After an apply of a parked record: drop its row only if the row is still that record. A parked +// apply runs on a COPY of the record, since the node can be replaced or erased while loadData runs, +// and a newer record that replaced it there must survive the older one's completion. +void UnparkIfSame(const std::wstring& key, uint64_t seq) { + auto it = g_parked.find(key); + if (it != g_parked.end() && it->second.seq == seq) Unpark(it); +} + void Park(const std::wstring& key, SR::SaveRecord&& rec, uint8_t senderSlot, size_t bytes) { - auto existing = g_parked.find(key); - if (existing != g_parked.end()) Unpark(existing); // a newer record replaces its own predecessor + DropParked(key); SenderUse& use = g_parkUse[senderSlot]; while (use.count >= kMaxParkedPerSender || use.bytes + bytes > kMaxParkBytesPerSender) { // This sender's own oldest row goes, never another sender's. @@ -291,17 +305,26 @@ bool TryApply(void* actor, const std::wstring& key, const SR::SaveRecord& rec) { return true; } -// Apply to the live actor if there is one, park by Key if there is not. -void LandRecord(const std::wstring& key, SR::SaveRecord&& rec, uint8_t senderSlot, size_t bytes) { +// Apply to the live actor if there is one, park by Key if there is not. Both arrival paths land +// here, so the predecessor rule is kept in one place. True when the record was applied. +// `budgeted` spends the per-frame apply budget; the host's take of a client intent does not, its +// rate being bounded per sender at the door. +bool LandRecord(const std::wstring& key, SR::SaveRecord&& rec, uint8_t senderSlot, size_t bytes, + bool budgeted) { + // The predecessor goes BEFORE the apply, so a record parked under this Key while loadData runs + // can only be a newer one, and nothing below removes it. + DropParked(key); // The index only, never ResolveLiveActorByKey: that one falls back to a cold GUObjectArray // scan on a miss, and a join hands this lane hundreds of arrivals at once. A prop the index // does not know yet is parked and retried at O(1) on the next frame instead. - void* actor = (g_applyBudget > 0) ? PT::FindLiveActorByKey(key) : nullptr; + void* actor = (!budgeted || g_applyBudget > 0) ? PT::FindLiveActorByKey(key) : nullptr; if (actor && TryApply(actor, key, rec)) { - --g_applyBudget; - return; + if (budgeted) --g_applyBudget; + return true; } + if (g_parked.count(key)) return false; // a newer record arrived during the apply; it stays Park(key, std::move(rec), senderSlot, bytes); + return false; } bool SendBody(coop::net::Session* s, int peerSlot, const std::wstring& key, @@ -513,7 +536,7 @@ void OnChunk(coop::net::Session& s, const coop::net::BlobChunkPayload& p, uint8_ return; } if (!intent) { - LandRecord(key, std::move(rec), senderSlot, body.size()); + LandRecord(key, std::move(rec), senderSlot, body.size(), /*budgeted=*/true); return; } // The host takes a client's claim: apply or park it locally, then re-publish the result as the @@ -529,13 +552,12 @@ void OnChunk(coop::net::Session& s, const coop::net::BlobChunkPayload& p, uint8_ key.c_str(), static_cast(senderSlot)); return; } - if (actor && TryApply(actor, key, rec)) { + if (LandRecord(key, SR::SaveRecord(rec), senderSlot, body.size(), /*budgeted=*/false)) { UE_LOGI("prop_save_data: HOST took a client record (key '%ls', slot %u) -- republishing", key.c_str(), static_cast(senderSlot)); } else { UE_LOGI("prop_save_data: HOST parked a client record (key '%ls', slot %u) -- " "republishing", key.c_str(), static_cast(senderSlot)); - Park(key, SR::SaveRecord(rec), senderSlot, body.size()); } SendBody(&s, -1, key, rec); } @@ -547,9 +569,10 @@ bool ApplyParked(void* actor, const std::wstring& key) { // ApplyRecord's bool is the DISPATCH's, not the Blueprint's: Aprop_C::loadData never assigns // its `return` out-param at all, so there is no refusal to read. A failure here is a codec or // reflection failure, and the record stays parked for the next frame to retry. - if (!TryApply(actor, key, it->second.rec)) return false; + const uint64_t seq = it->second.seq; + if (!TryApply(actor, key, SR::SaveRecord(it->second.rec))) return false; UE_LOGI("prop_save_data: parked record applied at birth (key '%ls')", key.c_str()); - Unpark(it); + UnparkIfSame(key, seq); return true; } @@ -600,31 +623,33 @@ void Drive() { while (looked < kParkScanPerFrame && g_applyBudget > 0 && !g_parked.empty()) { if (it == g_parked.end()) { it = g_parked.begin(); g_parkCursor.clear(); } ++looked; + // The Key by VALUE, and the walk resumes by it rather than by `it`: loadData re-runs the + // prop's init(), from which a re-entrant apply or land for this same Key can erase or + // replace the node this iterator names. + const std::wstring key = it->first; void* actor = (it->second.failures < kMaxApplyFailures) - ? PT::FindLiveActorByKey(it->first) : nullptr; + ? PT::FindLiveActorByKey(key) : nullptr; if (actor) { // The budget buys a DISPATCH, not a success: a row whose apply fails past the base // capture has already paid for one, and counting only successes let a handful of // failing rows pay a dispatch each, every frame, forever. --g_applyBudget; - // The Key by VALUE: TryApply reads it after loadData has run, and loadData re-runs the - // prop's init(), from which a re-entrant apply for this same Key would erase the node - // this iterator names. - const std::wstring key = it->first; - if (TryApply(actor, key, it->second.rec)) { + const uint64_t seq = it->second.seq; + if (TryApply(actor, key, SR::SaveRecord(it->second.rec))) { ++applied; - const auto dead = it++; - Unpark(dead); - continue; - } - if (++it->second.failures == kMaxApplyFailures) { - UE_LOGW("prop_save_data: the record for key '%ls' failed to apply %d times -- it " - "stays parked but is no longer retried; that prop keeps its own state", - it->first.c_str(), kMaxApplyFailures); + UnparkIfSame(key, seq); + } else { + auto row = g_parked.find(key); + if (row != g_parked.end() && row->second.seq == seq && + ++row->second.failures == kMaxApplyFailures) { + UE_LOGW("prop_save_data: the record for key '%ls' failed to apply %d times -- " + "it stays parked but is no longer retried; that prop keeps its own " + "state", key.c_str(), kMaxApplyFailures); + } } } - g_parkCursor = it->first; - ++it; + g_parkCursor = key; + it = g_parked.upper_bound(key); } if (applied && !g_parked.empty()) { UE_LOGI("prop_save_data: applied %d parked record(s) this frame (%zu still parked)", From c587b3d30f4ab58154e35e30d6fb8e36326cfdf8 Mon Sep 17 00:00:00 2001 From: Shinobu2 Date: Sun, 4 Oct 2026 14:04:39 +0000 Subject: [PATCH 002/306] [coop] a held mirror is tracker-known and is not expressed again EnsureHeldItemBroadcast asked the tracker's locals-only accessor whether a held prop is already shared, so a mirror read as untracked. A client that grabbed a host prop expressed it as a spawn with no eid, which the host refuses, and then sent its own copy of the prop's save record, which the host applied and re-published over its own. The decline now asks the registry reverse, which covers locals and mirrors alike. Evidence: two b208 host logs from players refuse a client PropSpawn with elementId=0 69 and 359 times; 48 and 247 follow a client GRAB-IN of a host eid, and 28 and 106 sit beside a client record the host took. Syntax-checked only; not yet built for Windows or run with two peers. Co-Authored-By: Claude Opus 5.5 (cherry picked from commit 56d7ec5660e74c8a70d07c3f187f7e7f1fe5f115) --- src/votv-coop/src/coop/props/trash_collect_sync.cpp | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/votv-coop/src/coop/props/trash_collect_sync.cpp b/src/votv-coop/src/coop/props/trash_collect_sync.cpp index 68b8e315d..6c7c85d12 100644 --- a/src/votv-coop/src/coop/props/trash_collect_sync.cpp +++ b/src/votv-coop/src/coop/props/trash_collect_sync.cpp @@ -14,6 +14,7 @@ #include "coop/net/protocol.h" #include "coop/net/session.h" #include "coop/element/quiescence_drain.h" // ArmGhostSweep +#include "coop/element/registry.h" // EidForActor, locals and mirrors alike #include "coop/props/prop_element_tracker.h" #include "coop/props/prop_sound.h" // client-own-grab pickup cue (native grab suppressed -> synthesize locally) #include "coop/props/prop_synth_key.h" @@ -272,17 +273,19 @@ bool EnsureHeldItemBroadcast(void* heldActor, coop::net::Session* s) { // load's late tail is keyed yet unknown to the wire (never expressed, never bound), and the // player carrying one was invisible to the host. The skip test is tracker-known: a prop with a // live Element is genuinely shared and the pose stream alone mirrors it; an untracked one is - // expressed here, key and all, the moment a player first touches it. + // expressed here, key and all, the moment a player first touches it. A MIRROR counts: it is a + // prop another peer expressed, so the registry reverse is asked rather than the tracker's + // locals-only accessor. Read as untracked, a mirror went out as a spawn with no eid, which its + // owner refuses, followed by this peer's copy of its save record, which the owner applied. std::wstring keyStr = ue_wrap::prop::GetInteractableKeyString(heldActor); - if (!keyStr.empty() && keyStr != L"None" && - PT::GetPropElementIdForActor(heldActor) != coop::element::kInvalidId) { + const coop::element::ElementId trackedEid = coop::element::Registry::Get().EidForActor(heldActor); + if (!keyStr.empty() && keyStr != L"None" && trackedEid != coop::element::kInvalidId) { // Logged with key and eid: "the pose stream suffices" holds only while the prop is actively // held and streamed, so a repro can tell a tracker-known decline from a never-received // spawn. UE_LOGI("[ROCK-DROP] EnsureHeldItemBroadcast DECLINE (tracker-known): cls='%ls' key='%ls' eid=%u " "-- 'pose stream suffices' assumes an active stream; false if the prop is no longer held", - R::ClassNameOf(heldActor).c_str(), keyStr.c_str(), - static_cast(PT::GetPropElementIdForActor(heldActor))); + R::ClassNameOf(heldActor).c_str(), keyStr.c_str(), static_cast(trackedEid)); return false; // keyed AND tracker-known: the peer has it; pose stream suffices } From 5ef718f23b1b1a3b235a4e059a14be3b742d8280 Mon Sep 17 00:00:00 2001 From: Shinobu2 Date: Sun, 4 Oct 2026 14:08:30 +0000 Subject: [PATCH 003/306] [coop] the host never defers a prop destroy to a load tail A destroy whose actor is absent is deferred only while a load tail may still bring it, a window HasLoadTailQuiesced closes. That latch is set by a client's join sweep and never on the host, and the drain that applies a deferred destroy is client-only, so the host armed every such destroy and kept it until teardown. The host now takes the steady-state no-op path. Evidence: a b208 host log from a player ends with "session teardown dropping 111 unresolved deferred destroy(s)", each armed under a CLIENT label on the host. Syntax-checked only; not built for Windows. Co-Authored-By: Claude Opus 5.5 (cherry picked from commit 291476241881664b69fc35ffb7c0fdd3673f31cc) --- src/votv-coop/src/coop/props/remote_prop_destroy.cpp | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/votv-coop/src/coop/props/remote_prop_destroy.cpp b/src/votv-coop/src/coop/props/remote_prop_destroy.cpp index 446c26f74..c340b85af 100644 --- a/src/votv-coop/src/coop/props/remote_prop_destroy.cpp +++ b/src/votv-coop/src/coop/props/remote_prop_destroy.cpp @@ -178,9 +178,12 @@ bool OnDestroyImpl_(const coop::net::PropDestroyPayload& payload, void* localPla // is no before-load: an absent eid is a host-only or already-gone prop this peer never // mirrored, and the destroy is an idempotent no-op. Arming it would pin the order // owner's pending-work flag forever and run the full-array reconcile several times a - // second in perpetuity. - if (coop::join_membership_sweep::HasLoadTailQuiesced()) { - UE_LOGI("remote_prop::OnDestroy: key '%ls' eid=%u no local actor in STEADY state (load tail " + // second in perpetuity. The host has no load tail at all: its world is its own, its + // quiescence latch is a client's join sweep and never fires there, and the drain that + // applies a deferred destroy is client-only, so on the host it is always this no-op. + if (coop::prop_element_tracker::SessionIsHost() || + coop::join_membership_sweep::HasLoadTailQuiesced()) { + UE_LOGI("remote_prop::OnDestroy: key '%ls' eid=%u no local actor in STEADY state (the host, or load tail " "quiesced) -- no-op drop (nothing to destroy; not a before-load race; no perpetual defer)", keyW.c_str(), payload.elementId); return false; From 07493cc4b6dcc738e0263972a5832aaf648b5242 Mon Sep 17 00:00:00 2001 From: Shinobu2 Date: Sun, 4 Oct 2026 23:07:05 -0700 Subject: [PATCH 004/306] [tools] the dev clock panel reads a game-thread snapshot The F1 clock panel read the day-night cycle from the render thread on every frame, which the hot-path guard reported as off-thread access. The panel now reads a snapshot the game thread refreshes at most four times a second. Evidence: eight HotPathGuard errors from ReadClock, ReadTimeZ and ReadMaxTime in a host log, resolved through main.map. Co-Authored-By: Claude Opus 5.5 (cherry picked from commit 36ac9c262cb22c25e906c65ebc1073c33d8f7230) --- src/votv-coop/include/coop/dev/set_clock.h | 3 +- src/votv-coop/src/coop/dev/set_clock.cpp | 66 ++++++++++++++++++---- 2 files changed, 57 insertions(+), 12 deletions(-) diff --git a/src/votv-coop/include/coop/dev/set_clock.h b/src/votv-coop/include/coop/dev/set_clock.h index 5ebbc3f6c..42721777b 100644 --- a/src/votv-coop/include/coop/dev/set_clock.h +++ b/src/votv-coop/include/coop/dev/set_clock.h @@ -23,7 +23,8 @@ namespace coop::dev::set_clock { // Read the live NAMED clock (displayed day = timeZ.Z + 1, hour/minute from timeZ) + the sun // FRACTION (totalTime/MaxTime in [0,1)). Returns false if the world clock is not resolved yet. -// Raw field reads (render-thread tolerable, the existing menu pattern). +// Any thread: a snapshot the game thread refreshes at most four times a second, so the first +// call after entering a world can return false once. bool ReadCurrent(int& hourOut, int& minuteOut, int& dayOut, float& sunFracOut); // HOST-only (dev_gate): set the clock to (displayed day, hour, minute). Clamps day >= 1, hour diff --git a/src/votv-coop/src/coop/dev/set_clock.cpp b/src/votv-coop/src/coop/dev/set_clock.cpp index 3d36e55d6..e0b77c393 100644 --- a/src/votv-coop/src/coop/dev/set_clock.cpp +++ b/src/votv-coop/src/coop/dev/set_clock.cpp @@ -7,26 +7,70 @@ #include "ue_wrap/core/game_thread.h" #include "ue_wrap/core/log.h" +#include +#include #include +#include namespace coop::dev::set_clock { namespace DNC = ue_wrap::daynightcycle; namespace GT = ue_wrap::game_thread; -bool ReadCurrent(int& hourOut, int& minuteOut, int& dayOut, float& sunFracOut) { +namespace { + +// The menu's view of the clock. The F1 panel renders on the render thread and the cycle's fields +// are the game thread's, so the panel reads this snapshot and asks the game thread to refresh it, +// at most four times a second. +struct Snapshot { bool ok = false; int hour = 0, minute = 0, day = 0; float sunFrac = 0.f; }; +std::mutex g_snapMu; +Snapshot g_snap; +std::atomic g_refreshPosted{false}; +std::atomic g_nextRefreshMs{0}; + +uint64_t NowMs() { + return static_cast(std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()).count()); +} + +// Game thread: read the live clock into the snapshot. +void Refresh() { + Snapshot n; int32_t h = 0, m = 0, d = 0; - if (!DNC::ReadTimeZ(h, m, d)) return false; float total = 0.f, dayAcc = 0.f, scale = 0.f, maxT = 0.f; - if (!DNC::ReadClock(total, dayAcc, scale)) return false; - if (!DNC::ReadMaxTime(maxT) || maxT <= 0.f) return false; - hourOut = h; - minuteOut = m; - dayOut = d + 1; // the game's own display convention (savedtime.Z + 1; save_browser fix) - float frac = total / maxT; - if (frac < 0.f) frac = 0.f; - if (frac > 1.f) frac = 1.f; - sunFracOut = frac; + if (DNC::ReadTimeZ(h, m, d) && DNC::ReadClock(total, dayAcc, scale) && DNC::ReadMaxTime(maxT) && + maxT > 0.f) { + n.ok = true; + n.hour = h; + n.minute = m; + n.day = d + 1; // the game's own display convention (savedtime.Z + 1; save_browser fix) + float frac = total / maxT; + if (frac < 0.f) frac = 0.f; + if (frac > 1.f) frac = 1.f; + n.sunFrac = frac; + } + std::lock_guard lk(g_snapMu); + g_snap = n; +} + +} // namespace + +bool ReadCurrent(int& hourOut, int& minuteOut, int& dayOut, float& sunFracOut) { + const uint64_t now = NowMs(); + if (now >= g_nextRefreshMs.load(std::memory_order_relaxed) && + !g_refreshPosted.exchange(true, std::memory_order_acq_rel)) { + g_nextRefreshMs.store(now + 250, std::memory_order_relaxed); + GT::Post([] { + Refresh(); + g_refreshPosted.store(false, std::memory_order_release); + }); + } + std::lock_guard lk(g_snapMu); + if (!g_snap.ok) return false; + hourOut = g_snap.hour; + minuteOut = g_snap.minute; + dayOut = g_snap.day; + sunFracOut = g_snap.sunFrac; return true; } From 617ff0f042e900b1131f2e10b67120e59ba3ae4f Mon Sep 17 00:00:00 2001 From: Shinobu2 Date: Sun, 4 Oct 2026 23:55:02 -0700 Subject: [PATCH 005/306] [coop] a peer puppet's run-ending travel revives nobody here A peer's puppet runs its own graph on this machine, and a false death of the copy reached the menu travel; the refusal then armed this machine's revive, healing and moving a player who had not died, and where no revive was available the travel passed and ended this machine's run. A travel a peer's puppet authors is now always refused, revives nothing, and only the black screen its chain may have put up is cleared. Evidence: a two-peer run logged the refusal with mainPlayer_C as author while the client's puppet burned on the host; built with MSVC 14.51. Co-Authored-By: Claude Opus 5.5 (cherry picked from commit 553cede671f1548cd44baebd3db365f6877e5671) --- docs/players.md | 5 ++++- .../include/coop/player/death_revive.h | 5 +++++ .../src/coop/player/death_revive.cpp | 19 +++++++++++++++++++ .../src/coop/player/run_end_travel.cpp | 14 ++++++++++++++ 4 files changed, 42 insertions(+), 1 deletion(-) diff --git a/docs/players.md b/docs/players.md index c31350a89..7f8b13bd6 100644 --- a/docs/players.md +++ b/docs/players.md @@ -142,7 +142,10 @@ ever setting the dead flag. Every one of them, dying included, asks for that tra function, and it carries the blueprint that asked as an argument. The mod watches that function at the script-body gate and reads the author: the pause menu's own quit is allowed through, because the player asked to leave, and anything else in a live co-op session is refused -(`coop/player/run_end_travel`). +(`coop/player/run_end_travel`). A peer's puppet is a player body too and its own graph runs on +this machine, so a false death of the copy reaches the same travel: that one is refused whatever +the revive can do, and this machine's player is not revived for it; only the black screen the +copy's chain put up is cleared. A refused run-ending is answered with a revive in its place: the position at the base gate, health, the dead flag, the ragdoll, the pause an ending set, and the screen artifacts the travel diff --git a/src/votv-coop/include/coop/player/death_revive.h b/src/votv-coop/include/coop/player/death_revive.h index 8da22fa4b..1101457c2 100644 --- a/src/votv-coop/include/coop/player/death_revive.h +++ b/src/votv-coop/include/coop/player/death_revive.h @@ -43,6 +43,11 @@ void NoteRunEndSeamReady(bool ready); // in the revive's line. Game thread (the gate's callback contract). void NoteRunEndCancelled(void* author, const wchar_t* authorClass, bool authorIsLocalPawn); +// A run-ending travel a peer's puppet authored here was refused: this machine's player did not die +// and is not revived, but the copy's death chain may have put a black screen on this viewport, which +// is cleared over the cleanup window. Any thread. +void NotePeerPuppetRunEndCancelled(); + // The unconditional watchdog, driven from the thread that POSTS the pump composite rather than // from inside it -- a lens found it riding in the composite, where a stalled game thread stops // the watchdog and the task it watches together. If a travel was cancelled and no revive has run diff --git a/src/votv-coop/src/coop/player/death_revive.cpp b/src/votv-coop/src/coop/player/death_revive.cpp index 4cf2d4745..ab26255e7 100644 --- a/src/votv-coop/src/coop/player/death_revive.cpp +++ b/src/votv-coop/src/coop/player/death_revive.cpp @@ -443,6 +443,10 @@ bool RunRevive(coop::net::Session& session, void* pawn) { constexpr int kScreenCleanupTicks = 120; constexpr int kScreenCleanupStride = 8; int g_screenCleanupLeft = 0; +// The black screen a peer's puppet's false death left on this viewport: its own window, so this +// machine's revive state is never touched by it. +std::atomic g_puppetResidue{false}; +int g_puppetBlackLeft = 0; // Re-attempt the three screen artifacts until they are provably gone; true when nothing is // left. Idempotent, one class lookup per artifact once clear. @@ -492,6 +496,8 @@ void NoteRunEndCancelled(void* author, const wchar_t* authorClass, bool authorIs g_cancelAtMsAtomic.store(::GetTickCount64(), std::memory_order_release); } +void NotePeerPuppetRunEndCancelled() { g_puppetResidue.store(true, std::memory_order_release); } + void OnSessionStart() { // Not the game thread (see the window's own note): every field it touches here is atomic. g_pawnFirstMs.store(0, std::memory_order_relaxed); @@ -501,6 +507,8 @@ void OnSessionStart() { g_armed.store(false, std::memory_order_release); g_revivePending.store(false, std::memory_order_release); g_cancelAtMsAtomic.store(0, std::memory_order_release); + g_puppetResidue.store(false, std::memory_order_release); + g_puppetBlackLeft = 0; g_wasDead = false; g_reviveRanThisDeath = false; g_lastReviveOk = false; @@ -598,6 +606,17 @@ void Tick(coop::net::Session& session, void* localPawn) { } if (haveState) g_wasDead = dead; + // A peer's puppet's refused run-ending: only its black screen, retried on the cleanup stride. + if (g_puppetResidue.exchange(false, std::memory_order_acq_rel)) g_puppetBlackLeft = kScreenCleanupTicks; + if (g_puppetBlackLeft > 0) { + const int tick = kScreenCleanupTicks - g_puppetBlackLeft--; + if (tick % kScreenCleanupStride == 0 && ClearBlackScreen()) { + g_puppetBlackLeft = 0; + UE_LOGI("death_revive: a peer puppet's run-ending left no black screen here (checked after %d " + "tick(s))", tick + 1); + } + } + // The deferred revive. if (g_revivePending.load(std::memory_order_acquire)) { if (g_cancelAtMs == 0) { diff --git a/src/votv-coop/src/coop/player/run_end_travel.cpp b/src/votv-coop/src/coop/player/run_end_travel.cpp index 12c9a692a..ab679baff 100644 --- a/src/votv-coop/src/coop/player/run_end_travel.cpp +++ b/src/votv-coop/src/coop/player/run_end_travel.cpp @@ -170,6 +170,20 @@ sg::Verdict OnLoadLevelPre(const sg::Call& call) { } g_menuSeen.fetch_add(1, std::memory_order_relaxed); + // A peer's puppet is a mainPlayer_C too, and its own graph runs here: a false death on the copy -- + // fire only this machine shows, a hit no impact cancel saw -- reaches the same travel. Nothing of + // this machine's run ended, so the travel is refused whatever the revive can do, and this + // machine's player is not revived: only the black screen the copy's chain put up is cleared. + if (author && author != coop::players::Registry::Get().Local() && + coop::players::Registry::Get().IsPuppet(author)) { + g_cancelled.fetch_add(1, std::memory_order_relaxed); + UE_LOGW("run_end_travel: REFUSED lib_C::loadLevel(\"menu\") authored by a peer's puppet " + "(slot %u) -- its run did not end on this machine; nothing is revived here", + static_cast(coop::players::Registry::Get().PeerIdOfActor(author))); + coop::death_revive::NotePeerPuppetRunEndCancelled(); + return sg::Verdict::Cancel; + } + const Judgement v = JudgeMenuTravel(author); if (v != Judgement::Cancel) { // Only the two that say something about this seam are worth a line per travel: a solo From 6984afff8d8a9cf92dcc97a327d18a2900890e04 Mon Sep 17 00:00:00 2001 From: Shinobu2 Date: Sun, 4 Oct 2026 23:55:03 -0700 Subject: [PATCH 006/306] [coop] a driven prop yields to this player's own grab at once The receiver of the host's driven-prop stream yielded only to a peer's held-prop stream, so between this player's grab and the host seeing it a host pose could park the prop out of the hand, and an end or a late pose could write it back. Every branch now yields to this player's grab slot and hand axis too, hands back the physics its park took, and closes the generation so a pose in flight cannot park it again. Evidence: review of 658e722 traced the gap in prop_drive_stream; built with MSVC 14.51. Co-Authored-By: Claude Opus 5.5 (cherry picked from commit caf6890039df4afab8f43d609bfc8c8fe7e60460) --- docs/props.md | 3 +- .../src/coop/props/prop_drive_stream.cpp | 32 +++++++++++++++---- 2 files changed, 27 insertions(+), 8 deletions(-) diff --git a/docs/props.md b/docs/props.md index 6f245930e..f12612b0d 100644 --- a/docs/props.md +++ b/docs/props.md @@ -165,7 +165,8 @@ frozen at the last pose across a gap -- where a held prop snaps to each pose; a carries the final pose, the host's physics flags and the velocity once the prop has rested or a hand has taken it, and closes the stream's generation so a pose still in flight cannot park the prop again (`coop/props/prop_drive_stream`). A hand always wins: a claimed prop somebody grabs leaves the -set and rides the held-prop stream. +set and rides the held-prop stream, and a receiver yields at once to its own player's grab, which +no stream from the host knows of yet, handing back the physics its park took. A mirror's physics and collision are set to what the game's own initialisation would have produced on this peer (`coop/props/prop_wire_parity`); a fresh mirror starts kinematic while it diff --git a/src/votv-coop/src/coop/props/prop_drive_stream.cpp b/src/votv-coop/src/coop/props/prop_drive_stream.cpp index 252e2854b..4c254d54e 100644 --- a/src/votv-coop/src/coop/props/prop_drive_stream.cpp +++ b/src/votv-coop/src/coop/props/prop_drive_stream.cpp @@ -8,6 +8,8 @@ #include "coop/props/prop_element_tracker.h" // FindLiveActorByKey: the index only, never the cold walk #include "coop/props/prop_park.h" // Park / Unpark: the parked prop, and a Character welded on it #include "coop/props/prop_wire_parity.h" // the join converge's own physics restore +#include "coop/player/local_streams.h" +#include "coop/player/hand_item.h" #include "coop/props/remote_prop.h" // ResolveLiveActorByEid, IsActorUnderAnyDrive #include "ue_wrap/actors/prop.h" #include "ue_wrap/core/hot_path_guard.h" @@ -23,6 +25,15 @@ namespace coop::prop_drive_stream { namespace { +// A hand owns the prop: a peer's held-prop stream, or this player's own grab -- its grab slot or the +// hotbar hand axis -- which no stream from the host knows of yet. Every branch of this drive yields to +// it, the first pose, the steady drive and both end paths, so a host stream that crossed the grab in +// flight never pulls the prop out of this player's hand. +bool HandOwns(void* actor) { + return coop::remote_prop::IsActorUnderAnyDrive(actor) || actor == coop::local_streams::LastHeldActor() || + coop::hand_item::IsHandAxisActor(actor); +} + namespace AD = coop::active_drive; namespace E = ue_wrap::engine; namespace PR = ue_wrap::prop; @@ -126,7 +137,7 @@ void TickApplyAndDrive(coop::net::Session& s) { if (it->second.gen == e.ctx) { // The steady state: the row already names the actor. No resolve, no string. if (void* live = it->second.d.LiveActor()) { - if (!coop::remote_prop::IsActorUnderAnyDrive(live)) // a hand's stream wins + if (!HandOwns(live)) // a hand's stream wins AD::BeginLerpToPose(it->second.d, ue_wrap::FVector{e.x, e.y, e.z}, ue_wrap::FRotator{e.pitch, e.yaw, e.roll}, nowMs); continue; @@ -150,7 +161,7 @@ void TickApplyAndDrive(coop::net::Session& s) { if (!PR::IsDescendantOfProp(actor)) continue; // A hand's stream on the same prop wins: the holder is its syncer for as long as it // holds it, and the host closes this stream the moment it sees the hand. - if (coop::remote_prop::IsActorUnderAnyDrive(actor)) continue; + if (HandOwns(actor)) continue; Drive& dr = g_drives[eid]; if (dr.d.actor && dr.d.actor != actor) GiveBackPhysics(dr, dr.d.LiveActor()); AD::ResetDriveState(dr.d); @@ -182,11 +193,18 @@ void TickApplyAndDrive(coop::net::Session& s) { Drive& dr = it->second; void* actor = dr.d.LiveActor(); if (!actor) { it = g_drives.erase(it); continue; } // the destroy crossed on its own seam - if (coop::remote_prop::IsActorUnderAnyDrive(actor)) { + if (HandOwns(actor)) { // A hand took it before the end edge landed: the holder's drive owns the actor from here, // and its release restores the physics. This drive yields for good rather than writing // a stale target back once the hand lets go. - UE_LOGI("[PROP-DRIVE] CLIENT yield eid=%u -- a held-prop stream owns the actor", it->first); + // This player's own hand gets the physics this park took, which no peer's release will + // restore; and the generation closes, so a pose of it still in flight cannot park it again. + const bool ownHand = actor == coop::local_streams::LastHeldActor() || + coop::hand_item::IsHandAxisActor(actor); + if (ownHand) GiveBackPhysics(dr, actor); + g_endedGen[it->first] = dr.gen; + UE_LOGI("[PROP-DRIVE] CLIENT yield eid=%u -- %s owns the actor", it->first, + ownHand ? "this player's hand" : "a held-prop stream"); it = g_drives.erase(it); continue; } @@ -216,7 +234,7 @@ void TickApplyAndDrive(coop::net::Session& s) { } void* actor = coop::remote_prop::ResolveLiveActorByEid(it->first); if (!actor) { ++it; continue; } - if (PR::IsDescendantOfProp(actor) && !coop::remote_prop::IsActorUnderAnyDrive(actor)) { + if (PR::IsDescendantOfProp(actor) && !HandOwns(actor)) { ApplyEnd(actor, it->second.p, /*parkedHere=*/false); UE_LOGI("[PROP-DRIVE] CLIENT END eid=%u gen=%u -> (%.1f,%.1f,%.1f) applied late (the " "prop arrived after its stream ended)", it->first, @@ -237,7 +255,7 @@ void OnEnd(const coop::net::PropDriveEndPayload& p) { g_endedGen[p.eid] = p.gen; void* actor = coop::remote_prop::ResolveLiveActorByEid(p.eid); if (actor) { - if (PR::IsDescendantOfProp(actor) && !coop::remote_prop::IsActorUnderAnyDrive(actor)) { + if (PR::IsDescendantOfProp(actor) && !HandOwns(actor)) { ApplyEnd(actor, p, /*parkedHere=*/false); UE_LOGI("[PROP-DRIVE] CLIENT END eid=%u gen=%u -> (%.1f,%.1f,%.1f) (never parked here)", p.eid, static_cast(p.gen), p.x, p.y, p.z); @@ -250,7 +268,7 @@ void OnEnd(const coop::net::PropDriveEndPayload& p) { Drive& dr = it->second; if (GenAfter(dr.gen, p.gen)) return; // an end for an older generation than the live drive if (void* actor = dr.d.LiveActor()) { - if (coop::remote_prop::IsActorUnderAnyDrive(actor)) { + if (HandOwns(actor)) { // A hand's stream parked this prop after ours did, and its release will hand the // physics back; touching the pose or the physics here would undo that park. UE_LOGI("[PROP-DRIVE] CLIENT END eid=%u gen=%u -- a held-prop stream owns the actor; " From aa3be888d168128c1bcd4b21459f0c512b4f7fed Mon Sep 17 00:00:00 2001 From: Shinobu2 Date: Mon, 5 Oct 2026 15:33:53 -0700 Subject: [PATCH 007/306] [coop] a host stream given up to a hand never writes its stale pose The first pose, the tick and the end edge now yield one way: this player's hand gets the physics back and the generation is marked superseded. A late end of that generation closes it without moving the prop, so a release in B is not undone by the host's old rest pose in A. An end that arrives before its first pose or before its prop still lands as before. Co-Authored-By: Claude Opus 5.5 (cherry picked from commit b3fdd6e228fa901db7bfab2b6fd37a759e2e5c37) --- .../src/coop/props/prop_drive_stream.cpp | 55 +++++++++++++++---- 1 file changed, 44 insertions(+), 11 deletions(-) diff --git a/src/votv-coop/src/coop/props/prop_drive_stream.cpp b/src/votv-coop/src/coop/props/prop_drive_stream.cpp index 4c254d54e..646ff0016 100644 --- a/src/votv-coop/src/coop/props/prop_drive_stream.cpp +++ b/src/votv-coop/src/coop/props/prop_drive_stream.cpp @@ -51,6 +51,9 @@ struct Drive { constexpr uint32_t kRelatchLoud = 60; std::unordered_map g_drives; // eid -> drive std::unordered_map g_endedGen; // eid -> the generation its end edge closed +// eid -> a generation a hand took over before its end edge landed. Its end pose is older than +// whatever the hand did since, so a late end of it closes the generation and writes nothing. +std::unordered_map g_yieldedGen; // A pose whose prop has not arrived here is retried on a throttle, never per tick: the host // re-sends every tick the prop moves, and though the resolve is two O(1) lookups, a line per @@ -93,6 +96,23 @@ void GiveBackPhysics(Drive& dr, void* actor) { if (simulate) dr.physicsParked = false; } +// The one way a stream gives a prop up to a hand, from the tick, the first pose and the end edge +// alike: this player's own hand gets back the physics the park took, which no peer's release will +// restore, and the generation is closed as superseded, so neither a pose nor the end of it can +// write its stale pose once the hand lets go. +bool IsOwnHand(void* actor) { + return actor == coop::local_streams::LastHeldActor() || coop::hand_item::IsHandAxisActor(actor); +} +void YieldToHand(uint32_t eid, uint8_t gen, Drive* dr, void* actor) { + if (dr && actor && IsOwnHand(actor)) GiveBackPhysics(*dr, actor); + g_endedGen[eid] = gen; + g_yieldedGen[eid] = gen; +} +bool YieldedAtOrAfter(uint32_t eid, uint8_t gen) { + auto y = g_yieldedGen.find(eid); + return y != g_yieldedGen.end() && !GenAfter(gen, y->second); +} + // The end edge on a live prop: the final pose, then the host's physics flags through the same // parity restore the join's converge applies after its teleport, then the velocity -- only into // a body this module parked and the flags leave simulating, and only when it is not zero, since @@ -161,7 +181,13 @@ void TickApplyAndDrive(coop::net::Session& s) { if (!PR::IsDescendantOfProp(actor)) continue; // A hand's stream on the same prop wins: the holder is its syncer for as long as it // holds it, and the host closes this stream the moment it sees the hand. - if (HandOwns(actor)) continue; + if (HandOwns(actor)) { + // The first pose found the prop already in a hand: the generation is the hand's. + YieldToHand(eid, e.ctx, nullptr, actor); + UE_LOGI("[PROP-DRIVE] CLIENT eid=%u gen=%u -- a hand holds it at the first pose; " + "generation yielded", eid, static_cast(e.ctx)); + continue; + } Drive& dr = g_drives[eid]; if (dr.d.actor && dr.d.actor != actor) GiveBackPhysics(dr, dr.d.LiveActor()); AD::ResetDriveState(dr.d); @@ -178,6 +204,7 @@ void TickApplyAndDrive(coop::net::Session& s) { dr.physicsParked = true; } if (ended != g_endedGen.end()) g_endedGen.erase(ended); + g_yieldedGen.erase(eid); // A pose of a closed generation never reaches here, so a pending end for this prop is // of an OLDER generation than the one parking now: stale, and dropped before it can // land on the new stream. @@ -197,12 +224,8 @@ void TickApplyAndDrive(coop::net::Session& s) { // A hand took it before the end edge landed: the holder's drive owns the actor from here, // and its release restores the physics. This drive yields for good rather than writing // a stale target back once the hand lets go. - // This player's own hand gets the physics this park took, which no peer's release will - // restore; and the generation closes, so a pose of it still in flight cannot park it again. - const bool ownHand = actor == coop::local_streams::LastHeldActor() || - coop::hand_item::IsHandAxisActor(actor); - if (ownHand) GiveBackPhysics(dr, actor); - g_endedGen[it->first] = dr.gen; + const bool ownHand = IsOwnHand(actor); + YieldToHand(it->first, dr.gen, &dr, actor); UE_LOGI("[PROP-DRIVE] CLIENT yield eid=%u -- %s owns the actor", it->first, ownHand ? "this player's hand" : "a held-prop stream"); it = g_drives.erase(it); @@ -234,7 +257,7 @@ void TickApplyAndDrive(coop::net::Session& s) { } void* actor = coop::remote_prop::ResolveLiveActorByEid(it->first); if (!actor) { ++it; continue; } - if (PR::IsDescendantOfProp(actor) && !HandOwns(actor)) { + if (PR::IsDescendantOfProp(actor) && !HandOwns(actor) && !YieldedAtOrAfter(it->first, it->second.p.gen)) { ApplyEnd(actor, it->second.p, /*parkedHere=*/false); UE_LOGI("[PROP-DRIVE] CLIENT END eid=%u gen=%u -> (%.1f,%.1f,%.1f) applied late (the " "prop arrived after its stream ended)", it->first, @@ -251,7 +274,14 @@ void OnEnd(const coop::net::PropDriveEndPayload& p) { if (it == g_drives.end()) { // Never parked here. The generation still closes, so a pose of it in flight cannot park // the prop now; the rest pose is applied if the prop is here, kept for it if not, and - // dropped only for a prop a hand holds -- that stream owns it. + // dropped for a prop a hand holds -- that stream owns it -- or one a hand took this generation + // over from: the hand moved it since, so this pose is older than where it lies. + if (YieldedAtOrAfter(p.eid, p.gen)) { + g_endedGen[p.eid] = p.gen; + UE_LOGI("[PROP-DRIVE] CLIENT END eid=%u gen=%u -- a hand took this generation over; " + "closed, pose not applied", p.eid, static_cast(p.gen)); + return; + } g_endedGen[p.eid] = p.gen; void* actor = coop::remote_prop::ResolveLiveActorByEid(p.eid); if (actor) { @@ -269,8 +299,10 @@ void OnEnd(const coop::net::PropDriveEndPayload& p) { if (GenAfter(dr.gen, p.gen)) return; // an end for an older generation than the live drive if (void* actor = dr.d.LiveActor()) { if (HandOwns(actor)) { - // A hand's stream parked this prop after ours did, and its release will hand the - // physics back; touching the pose or the physics here would undo that park. + // A hand took it before the tick yielded: the same yield, so this player's own hand gets + // the physics back. A peer's held-prop stream parked it after ours did and its release + // hands the physics back; the pose is left alone either way. + YieldToHand(p.eid, p.gen, &dr, actor); UE_LOGI("[PROP-DRIVE] CLIENT END eid=%u gen=%u -- a held-prop stream owns the actor; " "row dropped, nothing touched", p.eid, static_cast(p.gen)); } else { @@ -299,6 +331,7 @@ void OnDisconnect() { for (auto& kv : g_drives) GiveBackPhysics(kv.second, kv.second.d.LiveActor()); g_drives.clear(); g_endedGen.clear(); + g_yieldedGen.clear(); g_miss.clear(); g_pendingEnd.clear(); g_batch.clear(); From 0fcd63106dc59081bd3ab50caccb58edc7c2a0d6 Mon Sep 17 00:00:00 2001 From: Shinobu2 Date: Mon, 5 Oct 2026 19:40:08 -0700 Subject: [PATCH 008/306] [coop] the roach scale and the wisp montage jump use their real params Found in MultiVoidKindaPlus 0.9.2082's changelog and confirmed here: the roach scale went to SetWorldScale3D under NewScale3D (its parameter is NewScale), so the write failed and a zero scale hid the roach; the wisp's Montage_JumpToSection got MontageToJumpTo (it is Montage). The roach call is not made when the write fails. The log half of this commit is adopted on its own, as the PR's final log.cpp. Co-Authored-By: Claude Opus 5.5 (cherry picked from commit 160e4ecafce8eed1478c786ef1ff894fe57f4cb9) --- src/votv-coop/src/coop/creatures/roach_sync.cpp | 4 +++- src/votv-coop/src/ue_wrap/actors/wisp.cpp | 4 ++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/votv-coop/src/coop/creatures/roach_sync.cpp b/src/votv-coop/src/coop/creatures/roach_sync.cpp index e0a391dc8..6d88c9399 100644 --- a/src/votv-coop/src/coop/creatures/roach_sync.cpp +++ b/src/votv-coop/src/coop/creatures/roach_sync.cpp @@ -148,7 +148,9 @@ void SetCompScale(void* comp, float s) { Vec3 v{s, s, s}; ue_wrap::ParamFrame f(g_setScaleFn); if (!f.valid()) return; - f.SetRaw(L"NewScale3D", &v, sizeof(v)); + // USceneComponent::SetWorldScale3D(FVector NewScale): a write under another name fails and the call + // would go out with a zero scale, which hid the roach. No call without the write. + if (!f.SetRaw(L"NewScale", &v, sizeof(v))) return; ue_wrap::Call(comp, f); } diff --git a/src/votv-coop/src/ue_wrap/actors/wisp.cpp b/src/votv-coop/src/ue_wrap/actors/wisp.cpp index 66bd378d9..7ee76e0ae 100644 --- a/src/votv-coop/src/ue_wrap/actors/wisp.cpp +++ b/src/votv-coop/src/ue_wrap/actors/wisp.cpp @@ -452,14 +452,14 @@ bool PlayFatalityMontage(void* wisp) { f.Set(L"InPlayRate", 1.0f); if (!ue_wrap::Call(anim, f)) { UE_LOGW("wisp: Montage_Play call failed"); return false; } } - // Montage_JumpToSection('fatality', MontageToJumpTo). Skip-tolerant: if the jump fn is + // Montage_JumpToSection('fatality', Montage). Skip-tolerant: if the jump fn is // missing the montage still plays from the start (which begins with grab/d1.. anyway). if (g_montageJumpFn) { R::FName section = ue_wrap::fname_utils::StringToFName(L"fatality"); ue_wrap::ParamFrame f(g_montageJumpFn); if (f.valid()) { f.SetRaw(L"SectionName", §ion, sizeof(section)); - f.Set(L"MontageToJumpTo", g_fatalityMontage); + f.Set(L"Montage", g_fatalityMontage); ue_wrap::Call(anim, f); } } From 2c9beac374e46b998b819cff3bc881157971d38b Mon Sep 17 00:00:00 2001 From: Shinobu2 <32747633+Shinobu2@users.noreply.github.com> Date: Wed, 7 Oct 2026 11:50:47 -0700 Subject: [PATCH 009/306] [build] libopus links the static runtime the DLL links Opus's own CMake chooses its runtime from OPUS_STATIC_RUNTIME and does not inherit the /MT the rest of the DLL sets, so the codec built /MD into a /MT DLL: the vendor project names MultiThreadedDLL and a Release build logs LNK4098 for it. The option is now on. Taken from the CMake hunk of the live-session commit of #45. (cherry picked from commit e3b14fe2fed153035879a84a8acb2db86ab2e0ea) --- src/votv-coop/CMakeLists.txt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/votv-coop/CMakeLists.txt b/src/votv-coop/CMakeLists.txt index 9165d09b9..0cfa4db92 100644 --- a/src/votv-coop/CMakeLists.txt +++ b/src/votv-coop/CMakeLists.txt @@ -190,11 +190,12 @@ add_subdirectory(third_party/GameNetworkingSockets EXCLUDE_FROM_ALL) # --- voice chat substrate (v66; votv-voice-chat-port-design-2026-06-12.md) --- # libopus (BSD-3, vendored shallow clone pinned v1.5.2) -- the voice codec -# (the same lib Simple Voice Chat wraps via opus4j). Own CMake; inherits the -# global /MT runtime property set above. +# (the same lib Simple Voice Chat wraps via opus4j). Its CMake overrides the +# inherited runtime property unless OPUS_STATIC_RUNTIME is enabled. set(OPUS_BUILD_SHARED_LIBRARY OFF CACHE BOOL "" FORCE) set(OPUS_BUILD_TESTING OFF CACHE BOOL "" FORCE) set(OPUS_BUILD_PROGRAMS OFF CACHE BOOL "" FORCE) +set(OPUS_STATIC_RUNTIME ON CACHE BOOL "" FORCE) add_subdirectory(third_party/opus EXCLUDE_FROM_ALL) # miniaudio (public-domain/MIT-0 dual, vendored single header pinned 0.11.22) From e7a9cfd5e1b68e258a9ccdffb8e71c410d7b3b63 Mon Sep 17 00:00:00 2001 From: Shinobu2 Date: Sun, 4 Oct 2026 17:08:39 -0700 Subject: [PATCH 010/306] [coop] a joiner gets the daily task at world-ready and retries it The host sends the task only when its change hash moves, and a send to a joiner still loading is dropped by the world-ready gate while the hash is already taken, so a change made during the load never reached that peer. ConnectReplayForSlot now sends the current task to the joiner. A client that cannot apply a task keeps the latest one and retries it each second, instead of waiting for a host change that may never come; a disconnect drops it. The writes set the task's state only and pay no reward. Evidence: the hash filter, the gate and the missing replay call were read in the source; not yet built for Windows nor run with two peers. Co-Authored-By: Claude Opus 5.5 (cherry picked from commit 1126a2b0deddcd697f3a92ce25865aa9dff71492) --- docs/events-and-weather.md | 5 +- src/votv-coop/include/coop/net/protocol.h | 2 +- .../include/coop/world/daily_task_sync.h | 16 ++- src/votv-coop/src/coop/session/subsystems.cpp | 1 + .../src/coop/world/daily_task_sync.cpp | 109 ++++++++++++++---- 5 files changed, 102 insertions(+), 31 deletions(-) diff --git a/docs/events-and-weather.md b/docs/events-and-weather.md index b5e2fdaac..07977ccd4 100644 --- a/docs/events-and-weather.md +++ b/docs/events-and-weather.md @@ -148,7 +148,10 @@ at the laptop and the tab highlight, re-stamping the date from the synced clock; mirrored from either side (`coop/world/email_sync`). The daily task is host-authored, because every live writer of it runs only on the host: the task creation is part of the midnight a client's clock never reaches, and the drone sale is suppressed on clients; the host polls a change hash and sends the -task state (`coop/world/daily_task_sync`). The rewards land in the shared balance, on +task state, and sends it whole to a joiner at its world-ready, since a change during its load +was hashed but never reached it (`coop/world/daily_task_sync`). A receiver that cannot apply a +task yet keeps the latest one and retries it each second; the writes set the task's state and +pay no reward. The rewards land in the shared balance, on [devices.md](devices.md). ## Who owns what diff --git a/src/votv-coop/include/coop/net/protocol.h b/src/votv-coop/include/coop/net/protocol.h index b278fb1a8..2c211bf77 100644 --- a/src/votv-coop/include/coop/net/protocol.h +++ b/src/votv-coop/include/coop/net/protocol.h @@ -545,7 +545,7 @@ enum class ReliableKind : uint8_t { // From the presser, relayed: a wall-unit reel slot insert or eject. ReelSlotPayload. ReelSlot = 102, - // Host to all: the daily task mirror. TaskNewStatePayload. + // Host to all, and to a joiner at its world-ready: the daily task mirror. TaskNewStatePayload. TaskNewState = 103, // Client to host: my eject birthed this prop in my hands; author it. PropDropIntentPayload; diff --git a/src/votv-coop/include/coop/world/daily_task_sync.h b/src/votv-coop/include/coop/world/daily_task_sync.h index d42e835a9..88728f570 100644 --- a/src/votv-coop/include/coop/world/daily_task_sync.h +++ b/src/votv-coop/include/coop/world/daily_task_sync.h @@ -3,8 +3,9 @@ // the client's parked clock never reaches; lib.processTask -- reachable only via // setTaskNew + sell; droneSellLocation.sell -- the client drone tick is suppressed; // GUID census across all dumped assets). Wire: ReliableKind::TaskNewState=103 -// (host ~1 Hz change-hash poll; fires a few times per game-day). Rewards themselves -// ride the existing balance_sync (points) + email_sync (mail) lanes. +// (host ~1 Hz change-hash poll; fires a few times per game-day), plus the whole task to a +// joiner at its world-ready: a change made while it loaded was hashed but never reached it. +// Rewards themselves ride the existing balance_sync (points) + email_sync (mail) lanes. #pragma once @@ -19,12 +20,17 @@ namespace coop::daily_task_sync { // Store the session + log the install latch. Idempotent. Game thread. void Install(coop::net::Session* session); -// Per-net-pump tick: HOST-only ~1 Hz change-hash poll -> broadcast on change. -// A client tick is a no-op. Game thread. +// Per-net-pump tick: HOST ~1 Hz change-hash poll -> broadcast on change; CLIENT 1 Hz +// retry of a received task that did not apply. Game thread. void Tick(); +// HOST: the current task to one joiner, whatever the change hash holds (ConnectReplayForSlot, +// at its world-ready). Game thread. +void SendCurrentToSlot(int slot); + // Wire handler (CLIENT): one GT task applying scalars + the three int32 arrays -// (in-place or engine-realloc rebuild). Trust-gated to the host sender. +// (in-place or engine-realloc rebuild). A task that does not apply whole is kept and retried +// by Tick until it does; a newer one replaces it. Trust-gated to the host sender. void OnTaskNewState(const coop::net::TaskNewStatePayload& p, uint8_t senderSlot); // Session teardown: reset the hash baseline + timers. Game thread. diff --git a/src/votv-coop/src/coop/session/subsystems.cpp b/src/votv-coop/src/coop/session/subsystems.cpp index 0c109cd1b..797b101bb 100644 --- a/src/votv-coop/src/coop/session/subsystems.cpp +++ b/src/votv-coop/src/coop/session/subsystems.cpp @@ -370,6 +370,7 @@ void ConnectReplayForSlot(int slot) { coop::world_actor_sync::QueueConnectBroadcastForSlot(slot); // existing event WorldActors -> joiner coop::balance_sync::SendCurrentToSlot(slot); // host's current balance coop::upgrade_sync::SendCurrentToSlot(slot); // and the upgrade levels: the joiner's save was taken at the handshake + coop::daily_task_sync::SendCurrentToSlot(slot); // and the daily task: a change in its load window was hashed but never sent to it // The host-to-client apply-blob push is not here: this fires at ClientWorldReady, after the // joiner loaded its world, so the blob would miss the pre-materialise hook; the host tick's // connect-edge detector drives it pre-world instead. No join teleport here either: the diff --git a/src/votv-coop/src/coop/world/daily_task_sync.cpp b/src/votv-coop/src/coop/world/daily_task_sync.cpp index 8fc3f85a8..4ca114e9f 100644 --- a/src/votv-coop/src/coop/world/daily_task_sync.cpp +++ b/src/votv-coop/src/coop/world/daily_task_sync.cpp @@ -28,6 +28,15 @@ uint64_t g_nextPoll = 0; uint64_t g_lastHash = 0; bool g_haveHash = false; +// CLIENT: the latest host task not yet applied, retried at the poll cadence until it lands whole; +// a newer one replaces it. Game-thread state. The epoch moves at each disconnect, so a task the +// last session posted is dropped instead of reaching the next one. +std::atomic g_epoch{0}; +coop::net::TaskNewStatePayload g_pending{}; +bool g_havePending = false; +bool g_saidPending = false; +uint64_t g_nextRetry = 0; + uint64_t NowMs() { return static_cast(std::chrono::duration_cast( std::chrono::steady_clock::now().time_since_epoch()).count()); @@ -73,6 +82,51 @@ bool FillPayload(coop::net::TaskNewStatePayload& p) { return true; } +// Write a whole task into the local taskNew. A task already resolvable is read first, so the usual +// failure, the save slot not up yet, writes nothing. A failure past that point leaves the fields +// written so far; the retry writes every field again, so the struct still converges on this task. +// The writes set the task's state only: no reward runs from here. +bool ApplyTask(const coop::net::TaskNewStatePayload& cp) { + DT::View v{}; + if (!DT::Read(v)) return false; + int32_t buf[64]; + auto apply = [&buf](DT::Which which, const int16_t* src, uint8_t count) { + for (uint8_t i = 0; i < count; ++i) buf[i] = src[i]; + return DT::WriteArray(which, buf, count); + }; + const bool okScalars = DT::WriteScalars(cp.active != 0, cp.rewardSig, cp.rewardSat, + cp.reelBig, cp.reelSmall); + const bool okA = apply(DT::Which::SigRequired, cp.sigRequired, cp.sigRequiredCount); + const bool okB = apply(DT::Which::SigCompleted, cp.sigCompleted, cp.sigCompletedCount); + const bool okC = apply(DT::Which::RequiredDishes, cp.requiredDishes, cp.requiredDishesCount); + if (!okScalars || !okA || !okB || !okC) { + if (!g_saidPending) + UE_LOGW("[task] TaskNewState apply incomplete (scalars=%d arrays=%d/%d/%d) -- kept, retried", + okScalars, okA, okB, okC); + return false; + } + return true; +} + +// CLIENT, game thread: apply the pending task, or keep it for the next retry. +void TryApplyPending() { + if (!g_havePending) return; + if (!ApplyTask(g_pending)) { + if (!g_saidPending) { + g_saidPending = true; + UE_LOGI("[task] taskNew not applicable yet -- the host's task kept, retried each second"); + } + g_nextRetry = NowMs() + kPollMs; + return; + } + g_havePending = false; + g_saidPending = false; + UE_LOGI("[task] taskNew mirrored (active=%u sigReq=%u sigDone=%u dishes=%u " + "reelBig=%.1f reelSmall=%.1f)", + g_pending.active, g_pending.sigRequiredCount, g_pending.sigCompletedCount, + g_pending.requiredDishesCount, g_pending.reelBig, g_pending.reelSmall); +} + } // namespace void Install(coop::net::Session* session) { @@ -86,8 +140,12 @@ void Install(coop::net::Session* session) { void Tick() { UE_ASSERT_GAME_THREAD("daily_task_sync::Tick"); coop::net::Session* s = g_session.load(std::memory_order_acquire); - if (!s || !s->connected() || s->role() != coop::net::Role::Host) return; + if (!s || !s->connected()) return; const uint64_t now = NowMs(); + if (s->role() != coop::net::Role::Host) { + if (g_havePending && now >= g_nextRetry) TryApplyPending(); + return; + } if (now < g_nextPoll) return; g_nextPoll = now + kPollMs; coop::net::TaskNewStatePayload p{}; @@ -105,6 +163,20 @@ void Tick() { p.reelBig, p.reelSmall); } +void SendCurrentToSlot(int slot) { + UE_ASSERT_GAME_THREAD("daily_task_sync::SendCurrentToSlot"); + coop::net::Session* s = g_session.load(std::memory_order_acquire); + if (!s || s->role() != coop::net::Role::Host) return; + coop::net::TaskNewStatePayload p{}; + if (!FillPayload(p)) { + UE_LOGW("[task] slot %d world-ready -- taskNew unresolvable, no task sent", slot); + return; + } + s->SendReliableToSlot(slot, coop::net::ReliableKind::TaskNewState, &p, sizeof(p)); + UE_LOGI("[task] current taskNew sent to slot %d (active=%u sigReq=%u sigDone=%u dishes=%u)", + slot, p.active, p.sigRequiredCount, p.sigCompletedCount, p.requiredDishesCount); +} + void OnTaskNewState(const coop::net::TaskNewStatePayload& p, uint8_t senderSlot) { coop::net::Session* s = g_session.load(std::memory_order_acquire); if (!s) return; @@ -120,29 +192,14 @@ void OnTaskNewState(const coop::net::TaskNewStatePayload& p, uint8_t senderSlot) return; } const coop::net::TaskNewStatePayload cp = p; - GT::Post([cp]() { - // Widen i16 -> the engine's int32 arrays; ONE GT task = the whole apply is atomic - // w.r.t. every BP reader (all synchronous -- censused: getSigObj, kerfurOmega.findTask). - int32_t buf[64]; - auto apply = [&buf](DT::Which which, const int16_t* src, uint8_t count) { - for (uint8_t i = 0; i < count; ++i) buf[i] = src[i]; - return DT::WriteArray(which, buf, count); - }; - const bool okScalars = DT::WriteScalars(cp.active != 0, cp.rewardSig, cp.rewardSat, - cp.reelBig, cp.reelSmall); - const bool okA = apply(DT::Which::SigRequired, cp.sigRequired, cp.sigRequiredCount); - const bool okB = apply(DT::Which::SigCompleted, cp.sigCompleted, cp.sigCompletedCount); - const bool okC = apply(DT::Which::RequiredDishes, cp.requiredDishes, cp.requiredDishesCount); - if (!okScalars || !okA || !okB || !okC) { - UE_LOGW("[task] TaskNewState apply incomplete (scalars=%d arrays=%d/%d/%d) -- " - "taskNew unresolvable or an array rebuild failed; next broadcast retries", - okScalars, okA, okB, okC); - return; - } - UE_LOGI("[task] taskNew mirrored (active=%u sigReq=%u sigDone=%u dishes=%u " - "reelBig=%.1f reelSmall=%.1f)", - cp.active, cp.sigRequiredCount, cp.sigCompletedCount, cp.requiredDishesCount, - cp.reelBig, cp.reelSmall); + const uint32_t epoch = g_epoch.load(std::memory_order_acquire); + GT::Post([cp, epoch]() { + if (epoch != g_epoch.load(std::memory_order_acquire)) return; // posted by a session now gone + // Latest wins: this task replaces one still pending. An apply that succeeds is one GT task, so + // no BP reader (all synchronous -- censused: getSigObj, kerfurOmega.findTask) sees it half done. + g_pending = cp; + g_havePending = true; + TryApplyPending(); }); } @@ -151,6 +208,10 @@ void OnDisconnect() { g_nextPoll = 0; g_lastHash = 0; g_haveHash = false; + g_epoch.fetch_add(1, std::memory_order_acq_rel); + g_havePending = false; + g_saidPending = false; + g_nextRetry = 0; UE_LOGI("[task] daily_task_sync reset (disconnect)"); } From 386ed9496d8ba1c9809b849a7fe28a0997e9aa9f Mon Sep 17 00:00:00 2001 From: Shinobu2 Date: Sun, 4 Oct 2026 18:15:54 -0700 Subject: [PATCH 011/306] [coop] a joiner owed the daily task is retried, and a stale one dropped The world-ready send gave up when the task did not resolve or the send was refused, and the hash poll never resent it. Such a joiner is now owed the task and the host retries each second until it is sent or the slot is gone. A client drops a pending task that arrived in a world it has since left; that world's own task comes at its world-ready. Evidence: built with MSVC 14.51; not yet run in game. Co-Authored-By: Claude Opus 5.5 (cherry picked from commit 88418af517181153177957e4f254f8bd8c78e48a) --- docs/events-and-weather.md | 7 ++-- .../include/coop/world/daily_task_sync.h | 3 +- .../src/coop/world/daily_task_sync.cpp | 36 ++++++++++++++++--- 3 files changed, 38 insertions(+), 8 deletions(-) diff --git a/docs/events-and-weather.md b/docs/events-and-weather.md index 07977ccd4..77eee2ed7 100644 --- a/docs/events-and-weather.md +++ b/docs/events-and-weather.md @@ -149,9 +149,10 @@ mirrored from either side (`coop/world/email_sync`). The daily task is host-auth every live writer of it runs only on the host: the task creation is part of the midnight a client's clock never reaches, and the drone sale is suppressed on clients; the host polls a change hash and sends the task state, and sends it whole to a joiner at its world-ready, since a change during its load -was hashed but never reached it (`coop/world/daily_task_sync`). A receiver that cannot apply a -task yet keeps the latest one and retries it each second; the writes set the task's state and -pay no reward. The rewards land in the shared balance, on +was hashed but never reached it (`coop/world/daily_task_sync`); a joiner the task cannot be sent to +yet is owed it and the host retries each second. A receiver that cannot apply a task yet keeps the +latest one and retries it each second, and drops it if its world changes first; the writes set the +task's state and pay no reward. The rewards land in the shared balance, on [devices.md](devices.md). ## Who owns what diff --git a/src/votv-coop/include/coop/world/daily_task_sync.h b/src/votv-coop/include/coop/world/daily_task_sync.h index 88728f570..1deef8913 100644 --- a/src/votv-coop/include/coop/world/daily_task_sync.h +++ b/src/votv-coop/include/coop/world/daily_task_sync.h @@ -25,7 +25,8 @@ void Install(coop::net::Session* session); void Tick(); // HOST: the current task to one joiner, whatever the change hash holds (ConnectReplayForSlot, -// at its world-ready). Game thread. +// at its world-ready). One that cannot be sent yet is owed and retried by Tick until it is, or the +// slot is gone. Game thread. void SendCurrentToSlot(int slot); // Wire handler (CLIENT): one GT task applying scalars + the three int32 arrays diff --git a/src/votv-coop/src/coop/world/daily_task_sync.cpp b/src/votv-coop/src/coop/world/daily_task_sync.cpp index 4ca114e9f..d0e40a49d 100644 --- a/src/votv-coop/src/coop/world/daily_task_sync.cpp +++ b/src/votv-coop/src/coop/world/daily_task_sync.cpp @@ -8,7 +8,9 @@ #include "ue_wrap/core/game_thread.h" #include "ue_wrap/core/hot_path_guard.h" #include "ue_wrap/core/log.h" +#include "ue_wrap/engine/world_identity.h" +#include #include #include #include @@ -19,6 +21,7 @@ namespace { namespace DT = ue_wrap::daily_task; namespace GT = ue_wrap::game_thread; +namespace WI = ue_wrap::world_identity; constexpr uint64_t kPollMs = 1000; // host change-hash cadence (writers fire a few times/day) @@ -36,6 +39,11 @@ coop::net::TaskNewStatePayload g_pending{}; bool g_havePending = false; bool g_saidPending = false; uint64_t g_nextRetry = 0; +void* g_pendingWorld = nullptr; // the world the pending task arrived in; another world drops it + +// HOST: the joiners still owed the whole task -- its world-ready send could not be made (the task +// unresolvable, the send refused). Retried at the poll cadence until sent or the slot is gone. +std::array g_owed{}; uint64_t NowMs() { return static_cast(std::chrono::duration_cast( @@ -108,9 +116,16 @@ bool ApplyTask(const coop::net::TaskNewStatePayload& cp) { return true; } -// CLIENT, game thread: apply the pending task, or keep it for the next retry. +// CLIENT, game thread: apply the pending task, or keep it for the next retry. A task that arrived in +// another world is dropped: the host sends the new world its own at that world's world-ready. void TryApplyPending() { if (!g_havePending) return; + if (g_pendingWorld && WI::CurrentWorld() != g_pendingWorld) { + g_havePending = false; + g_saidPending = false; + UE_LOGI("[task] a pending task from the previous world dropped -- this world's comes at its world-ready"); + return; + } if (!ApplyTask(g_pending)) { if (!g_saidPending) { g_saidPending = true; @@ -148,6 +163,11 @@ void Tick() { } if (now < g_nextPoll) return; g_nextPoll = now + kPollMs; + for (int slot = 1; slot < static_cast(coop::net::kMaxPeers); ++slot) { + if (!g_owed[slot]) continue; + if (!s->IsSlotConnected(slot)) { g_owed[slot] = false; continue; } + SendCurrentToSlot(slot); + } coop::net::TaskNewStatePayload p{}; if (!FillPayload(p)) return; // taskNew unresolvable (menu / booting) -- retry next poll const uint64_t h = HashPayload(p); @@ -167,12 +187,17 @@ void SendCurrentToSlot(int slot) { UE_ASSERT_GAME_THREAD("daily_task_sync::SendCurrentToSlot"); coop::net::Session* s = g_session.load(std::memory_order_acquire); if (!s || s->role() != coop::net::Role::Host) return; + if (slot < 1 || slot >= static_cast(coop::net::kMaxPeers)) return; coop::net::TaskNewStatePayload p{}; - if (!FillPayload(p)) { - UE_LOGW("[task] slot %d world-ready -- taskNew unresolvable, no task sent", slot); + const bool filled = FillPayload(p); + if (!filled || !s->SendReliableToSlot(slot, coop::net::ReliableKind::TaskNewState, &p, sizeof(p))) { + if (!g_owed[slot]) + UE_LOGW("[task] slot %d -- the task could not be sent (%s); owed, retried each second", slot, + filled ? "send refused" : "taskNew unresolvable"); + g_owed[slot] = true; return; } - s->SendReliableToSlot(slot, coop::net::ReliableKind::TaskNewState, &p, sizeof(p)); + g_owed[slot] = false; UE_LOGI("[task] current taskNew sent to slot %d (active=%u sigReq=%u sigDone=%u dishes=%u)", slot, p.active, p.sigRequiredCount, p.sigCompletedCount, p.requiredDishesCount); } @@ -199,6 +224,7 @@ void OnTaskNewState(const coop::net::TaskNewStatePayload& p, uint8_t senderSlot) // no BP reader (all synchronous -- censused: getSigObj, kerfurOmega.findTask) sees it half done. g_pending = cp; g_havePending = true; + g_pendingWorld = WI::CurrentWorld(); TryApplyPending(); }); } @@ -212,6 +238,8 @@ void OnDisconnect() { g_havePending = false; g_saidPending = false; g_nextRetry = 0; + g_pendingWorld = nullptr; + g_owed.fill(false); UE_LOGI("[task] daily_task_sync reset (disconnect)"); } From efc2a4b4a2d6c30928dc920503260b24c65da14f Mon Sep 17 00:00:00 2001 From: Shinobu2 Date: Sun, 4 Oct 2026 17:08:39 -0700 Subject: [PATCH 012/306] [coop] a SAT console line runs only in the context it names The host set a typist's terminal context and ran the line whatever the init returned, and a dish index that did not resolve was passed as no dish and then kept as set up, so a later line skipped the repair. The context now answers ready, not yet, or bad; a line runs only when it is ready, else it is answered err and never re-run, since a command such as an eject acts on the world. A terminal still running a command answers a new line busy and keeps the context its continuation reads. Evidence: read in the source; not yet built for Windows nor run with two peers. Co-Authored-By: Claude Opus 5.5 (cherry picked from commit f1e065ad92493ba0e79f7551d551b8dacf124248) --- docs/signals.md | 6 ++- .../coop/interactables/sat_console_sync.cpp | 50 ++++++++++++++++--- 2 files changed, 48 insertions(+), 8 deletions(-) diff --git a/docs/signals.md b/docs/signals.md index 7afcd7f03..2695abef9 100644 --- a/docs/signals.md +++ b/docs/signals.md @@ -174,7 +174,11 @@ tower's state, the debug and joke commands that spawn or destroy things -- is re client and sent to the host with the terminal's context. The host keeps a terminal of the game's own class for each typist, never shown, and runs the line there, so the command happens once, in the host's world, and its results cross by their own lanes; every line that terminal prints comes -back to its typist alone, and so does its busy state. A line whose effect is the typist's own -- its +back to its typist alone, and so does its busy state. A line runs only once the host's terminal is +idle and set to the dish and name the line carried; a terminal still running a command answers it +busy, and a dish the host cannot resolve yet, or an init that fails, answers it err rather than +running it against another context. A line is never re-run on its own, since a command such as an +eject has an effect in the world. A line whose effect is the typist's own -- its compass, its terminal, the desk's radar filter and sounds on its machine -- runs where it was typed (`coop/interactables/sat_console_sync`, the command table in `coop/interactables/sat_console_table`). diff --git a/src/votv-coop/src/coop/interactables/sat_console_sync.cpp b/src/votv-coop/src/coop/interactables/sat_console_sync.cpp index f0309a960..3c21209d5 100644 --- a/src/votv-coop/src/coop/interactables/sat_console_sync.cpp +++ b/src/votv-coop/src/coop/interactables/sat_console_sync.cpp @@ -14,7 +14,7 @@ #include "ue_wrap/core/log.h" #include "ue_wrap/core/script_gate.h" #include "ue_wrap/desk/console_desk.h" // Instance: the desk a host terminal names as its panel -#include "ue_wrap/desk/dish.h" // IndexOf, DishByIndex +#include "ue_wrap/desk/dish.h" // IndexOf, DishByIndex, Count #include "ue_wrap/desk/sat_console.h" #include "ue_wrap/engine/world_identity.h" @@ -102,6 +102,7 @@ std::vector g_terms; bool g_saidRefused[kMaxPeers] = {}; bool g_saidMalformed[kMaxPeers] = {}; bool g_saidNoTerminal[kMaxPeers] = {}; +bool g_saidNoContext[kMaxPeers] = {}; // CLIENT: the host's busy level for this terminal, and whether a line this terminal ran itself holds // the flag: the mirror sets it while the host's terminal is busy and clears only what it set. @@ -330,13 +331,28 @@ Terminal* TerminalFor(uint8_t slot, const std::string& guid) { return &g_terms.back(); } -void ApplyContext(Terminal& t, int32_t dish, const std::wstring& name) { - if (t.haveContext && dish == t.dish && name == t.name) return; - void* d = dish >= 0 ? ue_wrap::dish::DishByIndex(dish) : nullptr; - if (!SC::CallInit(t.pin.Raw(), name, false, d)) return; +// Whether a terminal stands in the context a line names. A dish index that does not resolve is not +// ROOT: the line waits for its dish rather than running against none, and nothing is kept, so the +// next line with the same index tries again. An index past the host's dishes is no dish at all. +enum class Context { Ready, NotYet, Bad }; + +Context ApplyContext(Terminal& t, int32_t dish, const std::wstring& name) { + if (t.haveContext && dish == t.dish && name == t.name) return Context::Ready; + void* d = nullptr; + if (dish >= 0) { + d = ue_wrap::dish::DishByIndex(dish); + if (!d) { + const int32_t count = ue_wrap::dish::Count(); + return count > 0 && dish >= count ? Context::Bad : Context::NotYet; + } + } + // An init that fails may have set part of the context: nothing is kept, so the next line inits again. + t.haveContext = false; + if (!SC::CallInit(t.pin.Raw(), name, false, d)) return Context::NotYet; t.haveContext = true; t.dish = dish; t.name = name; + return Context::Ready; } void HostOnBlob(uint8_t slot, const std::vector& blob) { @@ -384,8 +400,27 @@ void HostOnBlob(uint8_t slot, const std::vector& blob) { AnswerRefused(slot, line, kBusyLine); // the terminal cannot take the line, as the game says it return; } + // A terminal still running a command keeps its context: its latent continuation reads the dish, + // name and panel it started with. The game's own terminal refuses a line while busy, so does this. + bool busy = true; + if (!SC::ReadProcessing(t->pin.Raw(), busy) || busy) { + AnswerRefused(slot, line, busy ? kBusyLine : kErrLine); + return; + } + // The line runs only in the context it names. One that cannot be set up is answered err and not + // retried: a command with an effect in the world must not run twice. + const Context ctx = ApplyContext(*t, dish < kNoDish ? kNoDish : dish, name); + if (ctx != Context::Ready) { + if (!g_saidNoContext[slot]) { + g_saidNoContext[slot] = true; + UE_LOGW("sat_console: HOST no context for slot %u's line (dish %d: %s) -- answered err", + static_cast(slot), dish, + ctx == Context::Bad ? "past the host's dishes" : "not resolved, or the init failed"); + } + AnswerRefused(slot, line, kErrLine); + return; + } if (spawns) t->nextSpawnOk = now + kSpawnEvery; - ApplyContext(*t, dish < kNoDish ? kNoDish : dish, name); SC::WriteUsed(t->pin.Raw(), SC::FindPanelByName(used)); UE_LOGI("sat_console: HOST runs '%ls' for slot %u (dish %d)", Printable(line).c_str(), static_cast(slot), dish); @@ -504,7 +539,7 @@ void OnPeerGone(uint8_t slot) { if (slot >= kMaxPeers) return; g_asm.ClearSlot(slot); g_out[slot] = Outbox{}; - g_saidRefused[slot] = g_saidMalformed[slot] = g_saidNoTerminal[slot] = false; + g_saidRefused[slot] = g_saidMalformed[slot] = g_saidNoTerminal[slot] = g_saidNoContext[slot] = false; for (Terminal& t : g_terms) { if (t.slot != slot) continue; t.slot = -1; // its command, if any, runs to its end; the terminal waits for its typist @@ -522,6 +557,7 @@ void OnDisconnect() { for (bool& b : g_saidRefused) b = false; for (bool& b : g_saidMalformed) b = false; for (bool& b : g_saidNoTerminal) b = false; + for (bool& b : g_saidNoContext) b = false; g_asm.Clear(); if (g_counts.linesSent || g_counts.linesRun) UE_LOGI("sat_console: session end -- %llu line(s) sent to the host, %llu run for clients", From 62993fcefcc476b6e4921b79267b386c4732903e Mon Sep 17 00:00:00 2001 From: Shinobu2 Date: Sun, 4 Oct 2026 18:15:54 -0700 Subject: [PATCH 013/306] [coop] a SAT console line naming a missing panel is answered err The host wrote the typist's last-used panel without checking it: a name that did not resolve became no panel, and the line ran against that. An empty name stays the ROOT answer; a named panel the host cannot find, or a write that fails, now answers the line err without running it. Evidence: built with MSVC 14.51; not yet run in game. Co-Authored-By: Claude Opus 5.5 (cherry picked from commit 608869cde1ff36cacb39f8a0bc3513e03d0083ce) --- docs/signals.md | 6 +++--- .../src/coop/interactables/sat_console_sync.cpp | 13 ++++++++++++- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/docs/signals.md b/docs/signals.md index 2695abef9..99f3c5e63 100644 --- a/docs/signals.md +++ b/docs/signals.md @@ -175,9 +175,9 @@ client and sent to the host with the terminal's context. The host keeps a termin own class for each typist, never shown, and runs the line there, so the command happens once, in the host's world, and its results cross by their own lanes; every line that terminal prints comes back to its typist alone, and so does its busy state. A line runs only once the host's terminal is -idle and set to the dish and name the line carried; a terminal still running a command answers it -busy, and a dish the host cannot resolve yet, or an init that fails, answers it err rather than -running it against another context. A line is never re-run on its own, since a command such as an +idle and set to the dish, name and panel the line carried; a terminal still running a command +answers it busy, and a dish or a named panel the host cannot resolve, or an init that fails, +answers it err rather than running it against another context. A line is never re-run on its own, since a command such as an eject has an effect in the world. A line whose effect is the typist's own -- its compass, its terminal, the desk's radar filter and sounds on its machine -- runs where it was typed (`coop/interactables/sat_console_sync`, the command table in `coop/interactables/sat_console_table`). diff --git a/src/votv-coop/src/coop/interactables/sat_console_sync.cpp b/src/votv-coop/src/coop/interactables/sat_console_sync.cpp index 3c21209d5..ee1cdbe92 100644 --- a/src/votv-coop/src/coop/interactables/sat_console_sync.cpp +++ b/src/votv-coop/src/coop/interactables/sat_console_sync.cpp @@ -420,8 +420,19 @@ void HostOnBlob(uint8_t slot, const std::vector& blob) { AnswerRefused(slot, line, kErrLine); return; } + // The panel the typist last used: none is a real answer (ROOT), a name that does not resolve here + // is not, and the line does not run against no panel in its place. + void* panel = used.empty() ? nullptr : SC::FindPanelByName(used); + if ((!used.empty() && !panel) || !SC::WriteUsed(t->pin.Raw(), panel)) { + if (!g_saidNoContext[slot]) { + g_saidNoContext[slot] = true; + UE_LOGW("sat_console: HOST no panel '%ls' for slot %u's line -- answered err", + Printable(used).c_str(), static_cast(slot)); + } + AnswerRefused(slot, line, kErrLine); + return; + } if (spawns) t->nextSpawnOk = now + kSpawnEvery; - SC::WriteUsed(t->pin.Raw(), SC::FindPanelByName(used)); UE_LOGI("sat_console: HOST runs '%ls' for slot %u (dish %d)", Printable(line).c_str(), static_cast(slot), dish); if (!SC::RunCommand(t->pin.Raw(), line)) { From eb030c85879bacdee7576d7b445900e7372f2b71 Mon Sep 17 00:00:00 2001 From: Shinobu2 Date: Mon, 5 Oct 2026 00:34:01 -0700 Subject: [PATCH 014/306] [coop] a SAT line answers err, not busy, when the flag is unreadable The host read a typist terminal's processing flag and answered busy both when it was set and when the read failed. A failed read now answers err; busy stays for a terminal that is busy. The damage-probe comments of the same commit go with the damage lane. Evidence: independent review of f1e065a and d516257; built with MSVC. Co-Authored-By: Claude Opus 5.5 (cherry picked from commit 6710bb241d8baa292c1ac5dad92c00feb4cf25b8) --- src/votv-coop/src/coop/interactables/sat_console_sync.cpp | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/votv-coop/src/coop/interactables/sat_console_sync.cpp b/src/votv-coop/src/coop/interactables/sat_console_sync.cpp index ee1cdbe92..2239948cf 100644 --- a/src/votv-coop/src/coop/interactables/sat_console_sync.cpp +++ b/src/votv-coop/src/coop/interactables/sat_console_sync.cpp @@ -402,9 +402,10 @@ void HostOnBlob(uint8_t slot, const std::vector& blob) { } // A terminal still running a command keeps its context: its latent continuation reads the dish, // name and panel it started with. The game's own terminal refuses a line while busy, so does this. - bool busy = true; - if (!SC::ReadProcessing(t->pin.Raw(), busy) || busy) { - AnswerRefused(slot, line, busy ? kBusyLine : kErrLine); + bool busy = false; + const bool readOk = SC::ReadProcessing(t->pin.Raw(), busy); + if (!readOk || busy) { + AnswerRefused(slot, line, readOk ? kBusyLine : kErrLine); // an unreadable flag is not "busy" return; } // The line runs only in the context it names. One that cannot be set up is answered err and not From c289dc8266a5ce3d0f23f29d8a4e8b4bba1f510c Mon Sep 17 00:00:00 2001 From: pelmentor Date: Fri, 9 Oct 2026 22:56:40 +0600 Subject: [PATCH 015/306] [coop] a hand's yield of a driven prop only moves its generation forward The yield wrote the generation it was handed into both the ended and the yielded record. The first pose yields the new generation, and the tick of the same pass can yield an older row of that eid, which wrote the older generation back: the newer one was reopened, its end found no yield, and the stale rest pose landed after the hand let go -- the case the yield exists to close. Each record now only advances. Co-Authored-By: Claude Opus 5.5 --- src/votv-coop/src/coop/props/prop_drive_stream.cpp | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/votv-coop/src/coop/props/prop_drive_stream.cpp b/src/votv-coop/src/coop/props/prop_drive_stream.cpp index 646ff0016..f6d6ace47 100644 --- a/src/votv-coop/src/coop/props/prop_drive_stream.cpp +++ b/src/votv-coop/src/coop/props/prop_drive_stream.cpp @@ -103,10 +103,18 @@ void GiveBackPhysics(Drive& dr, void* actor) { bool IsOwnHand(void* actor) { return actor == coop::local_streams::LastHeldActor() || coop::hand_item::IsHandAxisActor(actor); } +// Both records only ever move forward. The tick can yield an OLDER generation's row in the same +// pass that the first pose yielded a newer one, and writing the older one back reopened the newer +// generation: its end then found no yield and parked the stale rest pose after the hand let go. +void AdvanceGen(std::unordered_map& m, uint32_t eid, uint8_t gen) { + auto it = m.find(eid); + if (it == m.end()) m.emplace(eid, gen); + else if (GenAfter(gen, it->second)) it->second = gen; +} void YieldToHand(uint32_t eid, uint8_t gen, Drive* dr, void* actor) { if (dr && actor && IsOwnHand(actor)) GiveBackPhysics(*dr, actor); - g_endedGen[eid] = gen; - g_yieldedGen[eid] = gen; + AdvanceGen(g_endedGen, eid, gen); + AdvanceGen(g_yieldedGen, eid, gen); } bool YieldedAtOrAfter(uint32_t eid, uint8_t gen) { auto y = g_yieldedGen.find(eid); From 707b61799bfd798b1f19c12463cb3033c8b677cf Mon Sep 17 00:00:00 2001 From: pelmentor Date: Fri, 9 Oct 2026 22:57:49 +0600 Subject: [PATCH 016/306] [coop] a task kept from a left world goes at the next world-ready The client stamped a pending task with the raw world pointer and dropped it when the current world differed. That pointer reads null across a travel and in a degraded read, so a join's own task could be dropped mid-load and not come back until the next world-ready. The stale case is one event, this client's world-ready announce, after which the host's connect replay brings the new world's task: the pending one is dropped there and the stamp is gone. A departed slot now also clears what the host owed it, as the lanes beside it do. Co-Authored-By: Claude Opus 5.5 --- .../include/coop/world/daily_task_sync.h | 7 +++++ src/votv-coop/src/coop/session/net_pump.cpp | 2 ++ src/votv-coop/src/coop/session/subsystems.cpp | 1 + .../src/coop/world/daily_task_sync.cpp | 26 +++++++++---------- 4 files changed, 23 insertions(+), 13 deletions(-) diff --git a/src/votv-coop/include/coop/world/daily_task_sync.h b/src/votv-coop/include/coop/world/daily_task_sync.h index 1deef8913..1350d9d17 100644 --- a/src/votv-coop/include/coop/world/daily_task_sync.h +++ b/src/votv-coop/include/coop/world/daily_task_sync.h @@ -34,6 +34,13 @@ void SendCurrentToSlot(int slot); // by Tick until it does; a newer one replaces it. Trust-gated to the host sender. void OnTaskNewState(const coop::net::TaskNewStatePayload& p, uint8_t senderSlot); +// CLIENT, at this client's ClientWorldReady announce (net_pump): a task still pending was sent for +// the world it left, and the host's connect replay for this one follows the announce. Game thread. +void OnClientWorldReady(); + +// HOST: a departed slot owes nothing; a recycled one starts clean. Game thread. +void OnPeerGone(uint8_t slot); + // Session teardown: reset the hash baseline + timers. Game thread. void OnDisconnect(); diff --git a/src/votv-coop/src/coop/session/net_pump.cpp b/src/votv-coop/src/coop/session/net_pump.cpp index aab6bd6ec..4d6d87b1e 100644 --- a/src/votv-coop/src/coop/session/net_pump.cpp +++ b/src/votv-coop/src/coop/session/net_pump.cpp @@ -25,6 +25,7 @@ #include "coop/creatures/npc_adoption.h" #include "coop/creatures/kerfur_prop_adoption.h" // OnClientWorldReady: the deferred-adoption per-world reset #include "coop/world/event_fire_sync.h" // OnClientWorldReady: the fires queued in the join window +#include "coop/world/daily_task_sync.h" // OnClientWorldReady: a task kept from the world left behind #include "coop/player/puppet_drive.h" // the puppet array and its drive #include "coop/props/registry_reaper.h" // the reaper and the re-seed engine #include "coop/props/remote_prop_spawn.h" // OnClientWorldReadyResetSweep (deferred prop sweep per-world reset) @@ -458,6 +459,7 @@ void Tick(coop::net::Session& session) { coop::npc_adoption::OnClientWorldReady(); coop::kerfur_prop_adoption::OnClientWorldReady(); // drop the stale prop-kerfur pending set coop::event_fire_sync::OnClientWorldReady(); // the fires queued in the join window replay now + coop::daily_task_sync::OnClientWorldReady(); // a task kept from the world left behind goes // The same reset for the deferred prop divergence sweep: one armed for the prior // world must not fire against this one. coop::join_membership_sweep::OnClientWorldReadyResetSweep(); diff --git a/src/votv-coop/src/coop/session/subsystems.cpp b/src/votv-coop/src/coop/session/subsystems.cpp index 797b101bb..f933caad9 100644 --- a/src/votv-coop/src/coop/session/subsystems.cpp +++ b/src/votv-coop/src/coop/session/subsystems.cpp @@ -430,6 +430,7 @@ void DisconnectSlot(coop::net::Session& session, int slot) { coop::floppy_slot_sync::OnPeerGone(static_cast(slot)); coop::dish_hashcode_sync::OnPeerGone(static_cast(slot)); coop::sat_console_sync::OnPeerGone(static_cast(slot)); // its terminal kept, running its command, for its return + coop::daily_task_sync::OnPeerGone(static_cast(slot)); // a departed slot is owed no task coop::signal_sync::OnDisconnectSlot(slot); coop::email_sync::OnDisconnectSlot(slot); // Shut the chat lane's per-slot seed gate: the next occupant's applied range starts empty, so diff --git a/src/votv-coop/src/coop/world/daily_task_sync.cpp b/src/votv-coop/src/coop/world/daily_task_sync.cpp index d0e40a49d..2d446c1a3 100644 --- a/src/votv-coop/src/coop/world/daily_task_sync.cpp +++ b/src/votv-coop/src/coop/world/daily_task_sync.cpp @@ -8,7 +8,6 @@ #include "ue_wrap/core/game_thread.h" #include "ue_wrap/core/hot_path_guard.h" #include "ue_wrap/core/log.h" -#include "ue_wrap/engine/world_identity.h" #include #include @@ -21,7 +20,6 @@ namespace { namespace DT = ue_wrap::daily_task; namespace GT = ue_wrap::game_thread; -namespace WI = ue_wrap::world_identity; constexpr uint64_t kPollMs = 1000; // host change-hash cadence (writers fire a few times/day) @@ -39,7 +37,6 @@ coop::net::TaskNewStatePayload g_pending{}; bool g_havePending = false; bool g_saidPending = false; uint64_t g_nextRetry = 0; -void* g_pendingWorld = nullptr; // the world the pending task arrived in; another world drops it // HOST: the joiners still owed the whole task -- its world-ready send could not be made (the task // unresolvable, the send refused). Retried at the poll cadence until sent or the slot is gone. @@ -116,16 +113,10 @@ bool ApplyTask(const coop::net::TaskNewStatePayload& cp) { return true; } -// CLIENT, game thread: apply the pending task, or keep it for the next retry. A task that arrived in -// another world is dropped: the host sends the new world its own at that world's world-ready. +// CLIENT, game thread: apply the pending task, or keep it for the next retry. A task kept from a +// world this client has left is dropped at the new world's announce (OnClientWorldReady). void TryApplyPending() { if (!g_havePending) return; - if (g_pendingWorld && WI::CurrentWorld() != g_pendingWorld) { - g_havePending = false; - g_saidPending = false; - UE_LOGI("[task] a pending task from the previous world dropped -- this world's comes at its world-ready"); - return; - } if (!ApplyTask(g_pending)) { if (!g_saidPending) { g_saidPending = true; @@ -224,11 +215,21 @@ void OnTaskNewState(const coop::net::TaskNewStatePayload& p, uint8_t senderSlot) // no BP reader (all synchronous -- censused: getSigObj, kerfurOmega.findTask) sees it half done. g_pending = cp; g_havePending = true; - g_pendingWorld = WI::CurrentWorld(); TryApplyPending(); }); } +void OnClientWorldReady() { + if (!g_havePending) return; + g_havePending = false; + g_saidPending = false; + UE_LOGI("[task] a pending task from the previous world dropped -- this world's comes with its connect replay"); +} + +void OnPeerGone(uint8_t slot) { + if (slot < g_owed.size()) g_owed[slot] = false; +} + void OnDisconnect() { UE_ASSERT_GAME_THREAD("daily_task_sync::OnDisconnect"); g_nextPoll = 0; @@ -238,7 +239,6 @@ void OnDisconnect() { g_havePending = false; g_saidPending = false; g_nextRetry = 0; - g_pendingWorld = nullptr; g_owed.fill(false); UE_LOGI("[task] daily_task_sync reset (disconnect)"); } From b88fade8bdb642edeff087cd3a7ffb4c9a235dbb Mon Sep 17 00:00:00 2001 From: pelmentor Date: Fri, 9 Oct 2026 23:07:06 +0600 Subject: [PATCH 017/306] [coop] a drive row the host cannot write leaves the drive as it was The host spent a client's one-time row permission before writing, and the write minted the name and id into the live drive before converting the two name leaves, so a leaf the engine interns to None failed it half way: the drive kept a new name over its old signal, the next refresh sent that to every client, and the permission was gone. The write now stages every step that can fail and touches the drive only when all succeeded, a None leaf is refused as an insane row, and the permission is spent only by a write that landed; a failed one is answered like a refusal. Reported-by: Shinobu2 <32747633+Shinobu2@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 --- .../include/ue_wrap/desk/signal_dynamic.h | 4 +- .../src/coop/dev/meadow_selftest_rows.cpp | 2 +- .../coop/interactables/drive_payload_sync.cpp | 20 ++++++++-- .../src/ue_wrap/desk/signal_dynamic.cpp | 39 ++++++++++++------- 4 files changed, 45 insertions(+), 20 deletions(-) diff --git a/src/votv-coop/include/ue_wrap/desk/signal_dynamic.h b/src/votv-coop/include/ue_wrap/desk/signal_dynamic.h index 30a200428..a0d137a34 100644 --- a/src/votv-coop/include/ue_wrap/desk/signal_dynamic.h +++ b/src/votv-coop/include/ue_wrap/desk/signal_dynamic.h @@ -74,7 +74,9 @@ bool ReadStruct(const void* base, Row& out, bool withImage = false); // engine allocator), FNames interned via fname_utils. The previous string // buffers deliberately leak (pin doctrine; button-rate writes). The image // array is emptied (Num=0, allocation untouched) so a stale photo never -// attaches to a different signal. Game thread. +// attaches to a different signal. All or nothing: false leaves the struct +// as it was -- a leaf the engine interns to NAME_None ("None") or a string +// that cannot be minted writes no field at all. Game thread. bool WriteStructLive(void* base, const Row& in); // Build the 0x70 param-frame bytes for a struct PARAMETER (gamemode.saveSignal's diff --git a/src/votv-coop/src/coop/dev/meadow_selftest_rows.cpp b/src/votv-coop/src/coop/dev/meadow_selftest_rows.cpp index 16963d548..6e9fb777f 100644 --- a/src/votv-coop/src/coop/dev/meadow_selftest_rows.cpp +++ b/src/votv-coop/src/coop/dev/meadow_selftest_rows.cpp @@ -33,7 +33,7 @@ SD::Row MakeRow(RowId r) { SD::Row row; row.name = kName[r]; row.id = kId[r]; - row.object.clear(); // empty is NAME_None; the literal "None" trips WriteFNameField's failed-intern check + row.object.clear(); // empty is NAME_None; the literal "None" fails LeafToFName's failed-intern check row.signal.clear(); row.level = 1; row.size = 1.0f; diff --git a/src/votv-coop/src/coop/interactables/drive_payload_sync.cpp b/src/votv-coop/src/coop/interactables/drive_payload_sync.cpp index cdda11239..c2e880a0a 100644 --- a/src/votv-coop/src/coop/interactables/drive_payload_sync.cpp +++ b/src/votv-coop/src/coop/interactables/drive_payload_sync.cpp @@ -22,6 +22,7 @@ #include #include #include +#include #include #include #include @@ -263,11 +264,15 @@ void Park(uint32_t eid, const std::vector& blob, uint8_t senderSlot) { } } -// A client's row a host could write: finite and non-negative where the download reads it as an amount. +// A client's row a host could write: finite and non-negative where the download reads it as an amount, +// and no name leaf the engine interns to NAME_None -- a leaf "None" in any case, which the write refuses +// and no honest sender carries, since ReadFNameLeaf reads NAME_None as empty. bool RowSane(const SD::Row& r) { const float f[] = {r.size, r.decoded, r.downloadedAtQuality, r.locX, r.locY}; for (float v : f) if (!std::isfinite(v)) return false; + for (const std::wstring* leaf : {&r.object, &r.signal}) + if (::_wcsicmp(leaf->c_str(), L"None") == 0) return false; return r.size >= 0.f && r.decoded >= 0.f; } @@ -295,20 +300,27 @@ bool HostTakeClientRow(coop::net::Session* s, uint32_t eid, void* actor, const S if (author == g_brought.end() || author->second.slot != senderSlot || author->second.ref.Get() != actor) why = "not a drive that client brought"; else if (!IsClassDefault(actor, Hash(mine))) why = "the host already holds a row for it"; - else if (!RowSane(row)) why = "a row with a non-finite or negative amount"; + else if (!RowSane(row)) why = "a row with a non-finite or negative amount, or a None leaf"; if (why) { SendBytes(s, eid, Bytes(mine), senderSlot); ++g_counts.refused; SayRefusal(senderSlot, eid, why); return false; } - if (author != g_brought.end()) g_brought.erase(author); { ApplyScope scope; coop::desk_snd_fx::ScopedWireApply guard; - if (!DC::WriteDriveRow(actor, row)) return false; + if (!DC::WriteDriveRow(actor, row)) { + // The write is all or nothing, so the drive still holds the host's row: answered like a + // refusal, and the permission stands, since nothing landed. + SendBytes(s, eid, Bytes(mine), senderSlot); + ++g_counts.refused; + SayRefusal(senderSlot, eid, "a row the drive's write refused"); + return false; + } DC::CallDriveUpd(actor); } + g_brought.erase(eid); // the permission is spent by a row that landed, and only by one // Every other client gets it; its author holds it already (MTA sends an accepted change to all but its source, // CGame.cpp:2761-2768). HostSendIfChanged(s, eid, actor, /*exceptSlot*/ senderSlot); diff --git a/src/votv-coop/src/ue_wrap/desk/signal_dynamic.cpp b/src/votv-coop/src/ue_wrap/desk/signal_dynamic.cpp index b7278ace2..af6ff78f9 100644 --- a/src/votv-coop/src/ue_wrap/desk/signal_dynamic.cpp +++ b/src/votv-coop/src/ue_wrap/desk/signal_dynamic.cpp @@ -28,14 +28,13 @@ std::wstring ReadFNameLeaf(const uint8_t* p) { return R::ToString(n); } -bool WriteFNameField(uint8_t* p, const std::wstring& leaf) { - R::FName n{}; // NAME_None for empty input - if (!leaf.empty()) { - n = ue_wrap::fname_utils::StringToFName(leaf); - if (n.ComparisonIndex == 0 && n.Number == 0) return false; - } - std::memcpy(p, &n, sizeof(n)); - return true; +// A leaf as the FName the struct stores: NAME_None for an empty leaf, and false for a non-empty one +// the engine interns to NAME_None ("None" in any case), which no reader could tell from empty. +bool LeafToFName(const std::wstring& leaf, R::FName& out) { + out = R::FName{}; + if (leaf.empty()) return true; + out = ue_wrap::fname_utils::StringToFName(leaf); + return !(out.ComparisonIndex == 0 && out.Number == 0); } // A photo past this is no photo the laptop took (the local saves hold at most 8.7 KB): the array is @@ -83,10 +82,20 @@ bool ReadStruct(const void* base, Row& out, bool withImage) { bool WriteStructLive(void* base, const Row& in) { if (!base) return false; uint8_t* p = static_cast(base); - if (!ue_wrap::fstring_utils::MintFString(in.name, p + kOff_name)) return false; - if (!ue_wrap::fstring_utils::MintFString(in.id, p + kOff_id)) return false; - if (!WriteFNameField(p + kOff_object, in.object)) return false; - if (!WriteFNameField(p + kOff_signal, in.signal)) return false; + // Every step that can fail runs first, into locals, and the struct is written only once all of + // them have succeeded. Written in place, a failure stopped half way: a drive kept a new name and + // id over its old signal, and no caller could tell which row it now held. + R::FName object{}, signal{}; + if (!LeafToFName(in.object, object) || !LeafToFName(in.signal, signal)) return false; + uint8_t nameHeader[sizeof(R::FString)] = {}; + uint8_t idHeader[sizeof(R::FString)] = {}; + // A name minted before a failed id is left unreferenced, as every replaced string is (header). + if (!ue_wrap::fstring_utils::MintFString(in.name, nameHeader)) return false; + if (!ue_wrap::fstring_utils::MintFString(in.id, idHeader)) return false; + std::memcpy(p + kOff_name, nameHeader, sizeof(nameHeader)); + std::memcpy(p + kOff_id, idHeader, sizeof(idHeader)); + std::memcpy(p + kOff_object, &object, sizeof(object)); + std::memcpy(p + kOff_signal, &signal, sizeof(signal)); std::memcpy(p + kOff_level, &in.level, sizeof(in.level)); std::memcpy(p + kOff_polarity, &in.polarity, sizeof(in.polarity)); std::memcpy(p + kOff_size, &in.size, sizeof(in.size)); @@ -121,8 +130,10 @@ bool BuildParamBytes(const Row& in, uint8_t out[kStride]) { }; setStr(kOff_name, in.name); setStr(kOff_id, in.id); - if (!WriteFNameField(out + kOff_object, in.object)) return false; - if (!WriteFNameField(out + kOff_signal, in.signal)) return false; + R::FName object{}, signal{}; + if (!LeafToFName(in.object, object) || !LeafToFName(in.signal, signal)) return false; + std::memcpy(out + kOff_object, &object, sizeof(object)); + std::memcpy(out + kOff_signal, &signal, sizeof(signal)); std::memcpy(out + kOff_level, &in.level, sizeof(in.level)); std::memcpy(out + kOff_polarity, &in.polarity, sizeof(in.polarity)); std::memcpy(out + kOff_size, &in.size, sizeof(in.size)); From 422e4cf274725c11873c55f5b3d79514ef167ae0 Mon Sep 17 00:00:00 2001 From: Shinobu2 <32747633+Shinobu2@users.noreply.github.com> Date: Tue, 6 Oct 2026 23:57:17 -0700 Subject: [PATCH 018/306] [atv] a runtime ATV spawn waits for a ready world; a dead row spawns A client dropped a runtime ATV spawn when its world was not ready for it, and later state packets only move an existing ATV, so it stayed missing until a rejoin; a dead row of a previous world also counted as already spawned. A spawn now waits in its own queue until the client is in a gameplay world whose index is current and whose load has quiesced, a destroy cancels it, and a dead row no longer counts. Loading neither binds a world generation nor spends an attempt. The lane's net change across the five commits below; their TEST: subjects are dropped here. Co-Authored-By: Claude Opus 5.5 Co-Authored-By: Codex (cherry picked from commit d356a0c73042c0b97a8096316c8e8fcbd2387883) (cherry picked from commit bb02a9f5cb1ac297bb5027ac2a99d905a03b2c34) (cherry picked from commit c9f77223e883fd23ae2ee73da66e32c6f1e371a4) (cherry picked from commit 68b4917868b3704eb65621e236fc767da1c3622e) (cherry picked from commit b06fad1355cd2c162ae92ec7142f0eb09b3181ee) --- docs/code-map.md | 2 +- docs/vehicles.md | 3 + src/votv-coop/sources.cmake | 1 + .../coop/interactables/atv_spawn_retry.cpp | 79 +++++++++++++++++++ .../src/coop/interactables/atv_spawn_retry.h | 11 +++ .../src/coop/interactables/atv_sync.cpp | 36 ++++++++- 6 files changed, 127 insertions(+), 5 deletions(-) create mode 100644 src/votv-coop/src/coop/interactables/atv_spawn_retry.cpp create mode 100644 src/votv-coop/src/coop/interactables/atv_spawn_retry.h diff --git a/docs/code-map.md b/docs/code-map.md index 2834e64e3..e8931273e 100644 --- a/docs/code-map.md +++ b/docs/code-map.md @@ -25,7 +25,7 @@ One folder, one concept, named after it. `src/votv-coop/src/` holds the implemen | `coop/player/` | remote players and per-player state | `remote_player` + `remote_player_ragdoll`, `puppet_drive`, `puppet_carry_drive`, `puppet_body_yaw`, `puppet_footsteps` (driving the engine pawn), `players_registry`, `player_profile_store` (the host's copy of every player's profile: held in memory, cut to disk with the world save), `roster` + `roster_ledger` (who is in each slot), `nickname_arbiter`, `nick_color`, `nameplate`, `hand_item`, `local_body`, `local_streams`, `client_model` + `skin_registry` + `skin_preview` + `skin_effects` (skins), `movement_ledger`, `player_damage`, `death_revive` + `run_end_travel` (a trip to the menu the player did not ask for is refused and answered with a revive), `ragdoll_gate`, `sleep_sync`, `item_activate`, `flashlight_click_sound`, `stat_orders` (an admin's order to, and query of, one player's stats and effects) + `stat_orders_wire` (their bytes, their checks, and the table of the ones awaiting an answer) | | `coop/props/` | physics props, piles and trash | `prop_lifecycle` (the destroy seam), `prop_snapshot` + `snapshot_census` (the connect snapshot), `remote_prop` with `_spawn`, `_convert`, `_physics`, `_destroy` (the receivers), `host_spawn_watcher`, `prop_key_index`, `prop_synth_key`, `save_identity_bind` + `save_identity_map` (bind save-loaded actors to host eids in the join window), `pile_spawn_bind`, `trash_mirror` (the client's mirror of either trash form, the game's own actor parked), `trash_morph_gate` (the verbs a client refuses so it never authors a transition), `trash_sweep` (the clumps a broom stroke sets rolling: their ids at birth, a carry nobody holds, the roll), `trash_channel`, `trash_grab_intent`, `trash_collect_sync`, `trash_pile_sync`, `trash_use_intercept`, `trash_clump_pose_stream`, `prop_drop_intent`, `prop_spawn_authoring` (did a PLAYER's spawn verb author this birth, or the world's own churn -- the fact the drop-intent seam admits a catalog spawn on), `prop_save_data` (each prop's own save record, by Key), `prop_stick_sync`, `container_contents_sync` + `container_write_policy` (a world container slice, and the host arbitration a client-authored one passes) + `container_park` (the holding pen for a slice whose container has not arrived yet) + `container_view_close` (a player's view into a container ends where the player's reach does), `grab_observer`, `registry_reaper`, `join_membership_sweep`, `unresolved_pose_ledger`, `prop_echo_suppress`, `prop_wire_parity`, `prop_census`, `prop_container_extract`, `prop_destroy_seam`, `prop_fresh_spawn`, `prop_element_tracker`, `prop_sound`, `active_drive` | | `coop/creatures/` | NPCs and creatures | `npc_sync` + `npc_sync_install` (the spawn seam), `npc_mirror`, `npc_adoption`, `npc_pose_host`, `npc_pose_drive`, `npc_world_enum`, `kerfur_convert` with `_host` and `_client`, `kerfur_entity`, `kerfur_reconcile`, `kerfur_prop_adoption`, `kerfur_command`, `kerfur_menu_input`, `kerfur_form_assembler` (the prop-to-NPC conversion cycle), `owner_entity_sync` (creatures whose AI reads the local player: owned per peer, visible to all), `piramid_sync`, `roach_sync`, `wisp_attack_sync`, `wisp_grab_hold`, `wisp_tear_mirror` | -| `coop/interactables/` | keyed device state | doors, lights, light groups, containers, garage, appliances, lockers: `interactable_sync` + `interactable_channel`; `keypad_sync` + `keypad_verbs`, `verb_lanes` (the lanes on a device's own verbs at the script gate, wired in one place), `toggle_verbs` (a symmetric device's state at the verb that writes it), `window_sync`, `grime_sync`, `turbine_sync`, `drone_sync`, `device_occupancy`, `garbage_sync`. The signal workstation: `signal_sync`, `signal_catch_sync`, `signal_wire`, `dish_sync`, `dish_calib_sync`, `download_arm_sync` (the download's arm and reset, the host's), `desk_input_sync`, `desk_sim_sync`, `desk_ping_sync` (the ping's verdict, rolled on the host), `desk_verb_intent` and `desk_verb_effects` (the desk's save-family presses, run by the host, their gloss, stat and sounds sent to the presser), `desk_cursor_sync`, `desk_snd_fx`, `deck_play_sync`, `comp_sync` (the refiner's decode, the host's), `console_state_sync`, `drive_sync`, `drive_payload_sync` (a drive's row, the host's), `eraser_press_intent` (the eraser's delete, run by the host), `drive_rack_sync`, `tape_caddy_sync`, `physmods_sync`, `serverbox_sync`, `server_upgrade_sync` (a server box's physical upgrades), `sat_console_sync` and `sat_console_table` (the SAT console's shared commands, run on a terminal the host keeps for each typist, and which command runs where), `laptop_sync`, `portable_pc_lid` (the portable PC's lid, on its own verb), `laptop_buffer_sync`, `floppybox_sync`, `floppy_slot_sync` and `floppy_slot_entry` (a device's disc slot: the state, and which overlap into it counts as an insert), `meadow_db_sync`, `meadow_db_writers`, `meadow_db_join`, `meadow_db_park` and `meadow_db_hash` (the laptop's signal database, the bodies that write it, a joiner's seed of it, its inbound lines that cannot apply yet, and the database as content hashes). The ATV: `atv_sync`, `atv_corrector`, `atv_hit_guard`, `atv_condition_sync` | +| `coop/interactables/` | keyed device state | doors, lights, light groups, containers, garage, appliances, lockers: `interactable_sync` + `interactable_channel`; `keypad_sync` + `keypad_verbs`, `verb_lanes` (the lanes on a device's own verbs at the script gate, wired in one place), `toggle_verbs` (a symmetric device's state at the verb that writes it), `window_sync`, `grime_sync`, `turbine_sync`, `drone_sync`, `device_occupancy`, `garbage_sync`. The signal workstation: `signal_sync`, `signal_catch_sync`, `signal_wire`, `dish_sync`, `dish_calib_sync`, `download_arm_sync` (the download's arm and reset, the host's), `desk_input_sync`, `desk_sim_sync`, `desk_ping_sync` (the ping's verdict, rolled on the host), `desk_verb_intent` and `desk_verb_effects` (the desk's save-family presses, run by the host, their gloss, stat and sounds sent to the presser), `desk_cursor_sync`, `desk_snd_fx`, `deck_play_sync`, `comp_sync` (the refiner's decode, the host's), `console_state_sync`, `drive_sync`, `drive_payload_sync` (a drive's row, the host's), `eraser_press_intent` (the eraser's delete, run by the host), `drive_rack_sync`, `tape_caddy_sync`, `physmods_sync`, `serverbox_sync`, `server_upgrade_sync` (a server box's physical upgrades), `sat_console_sync` and `sat_console_table` (the SAT console's shared commands, run on a terminal the host keeps for each typist, and which command runs where), `laptop_sync`, `portable_pc_lid` (the portable PC's lid, on its own verb), `laptop_buffer_sync`, `floppybox_sync`, `floppy_slot_sync` and `floppy_slot_entry` (a device's disc slot: the state, and which overlap into it counts as an insert), `meadow_db_sync`, `meadow_db_writers`, `meadow_db_join`, `meadow_db_park` and `meadow_db_hash` (the laptop's signal database, the bodies that write it, a joiner's seed of it, its inbound lines that cannot apply yet, and the database as content hashes). The ATV: `atv_sync`, `atv_spawn_retry` (bounded runtime births), `atv_corrector`, `atv_hit_guard`, `atv_condition_sync` | | `coop/world/` | global and ambient state | `time_sync`, `sky_sync`, `weather_sync` with `weather_rain`, `weather_fog`, `weather_lightning`, `weather_redsky`, `weather_event_births`, `firefly_sync`, `spawn_authority` (shared-world spawners run on the host only), `event_fire_sync`, `event_active_sync`, `event_cue_sync`, `alarm_sync`, `balance_sync`, `email_sync`, `daily_task_sync`, `power_grid` (the generators run on the host) + `power_decay` (their wear dice) + `power_puzzle` (their repair puzzles) + `power_upgrade` (the upgrades an install spent) + `power_panel` (the panel's one author), `world_actor_sync` + `world_actor_mirror` | | `coop/items/` | inventory, the economy, and the deployed items | `player_inventory_sync` + `inventory_wire`, `inventory_pickup_sync`, `order_sync` (shop orders: the client names the row, the host performs and prices it) + `order_queue_sync` (the host's delivery queue mirrored on the clients) + `order_rows` (an order's items on the wire), `coingun_sync` + `coingun_arbiter` + `coingun_collect` (the sell gun and its coins), `save_record_wire`, `hook_sync` + `hook_anchor` + `hook_prop_claim` + `hook_constraint` (the grappling hook and the rope: the owner's stream and the mirrors, the anchor handoff, the props its ties claim, and the tie itself, which lives on the host), `broom_stroke` + `broom_push` (the broom: every stroke is the host's, a client's queued with what it read of its holder, and what a stroke pushes or knocks out of a dispenser streams) | | `coop/comms/` | text chat | `chat_sync`, `chat_feed`, `chat_bubbles`, `chat_log`, `chat_nick_color`, `peer_action_feed` | diff --git a/docs/vehicles.md b/docs/vehicles.md index 5e57fdb08..c5ec2368a 100644 --- a/docs/vehicles.md +++ b/docs/vehicles.md @@ -47,6 +47,9 @@ times a second, keyed by the ATV's save key; the host relays a client's stream. synced by the host at five hertz gated on change with a two-second keepalive floor, so a parked vehicle costs one packet every two seconds. An ATV that appears at runtime, from the spawn menu, has no save twin, so the host mints a synthetic key and announces it. +The client parks a valid announcement until gameplay, the discovery index and load quiescence +agree; loading spends no spawn attempt. Only an attempted spawn binds the pending row to a +world generation. `[?]` This readiness path still needs a two-peer join and travel test. ### Condition diff --git a/src/votv-coop/sources.cmake b/src/votv-coop/sources.cmake index 8e78e4c1c..1e4ac6b11 100644 --- a/src/votv-coop/sources.cmake +++ b/src/votv-coop/sources.cmake @@ -223,6 +223,7 @@ set(VOTVCOOP_SOURCES src/coop/world/coord_tower_ops.cpp src/coop/world/coord_tower_rows.cpp src/coop/interactables/atv_sync.cpp + src/coop/interactables/atv_spawn_retry.cpp src/coop/interactables/atv_condition_sync.cpp src/coop/interactables/atv_corrector.cpp src/coop/interactables/atv_hit_guard.cpp diff --git a/src/votv-coop/src/coop/interactables/atv_spawn_retry.cpp b/src/votv-coop/src/coop/interactables/atv_spawn_retry.cpp new file mode 100644 index 000000000..6eaae0e13 --- /dev/null +++ b/src/votv-coop/src/coop/interactables/atv_spawn_retry.cpp @@ -0,0 +1,79 @@ +// coop/interactables/atv_spawn_retry.cpp -- bounded runtime-ATV spawn queue. +#include "atv_spawn_retry.h" +#include "coop/interactables/atv_sync.h" +#include "coop/net/protocol.h" +#include "ue_wrap/core/log.h" +#include "ue_wrap/engine/world_identity.h" +#include +#include +#include +#include + +namespace coop::atv_sync::spawn_retry { +// A spawn that could not run yet (the wrapper unresolved, or the spawn itself failed) waits here and is retried, so a +// transient miss does not leave the ATV missing until a rejoin: the pose stream only moves an ATV that exists. A +// destroy cancels the wait; a spawn that lands, the budget running out, or the world leaving the stamped generation +// ends it. +struct PendingSpawn { + coop::net::AtvSpawnPayload payload{}; + uint32_t tries = 0; + uint64_t nextMs = 0; + uint32_t worldGen = 0; + bool worldBound = false; // bind only when the receiving world is ready +}; +std::unordered_map g_pendingSpawn; +constexpr uint32_t kSpawnTries = 20; +constexpr uint64_t kSpawnRetryMs = 500; +constexpr size_t kSpawnPendingCap = 16; + +uint64_t SpawnNowMs() { + return static_cast(std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()).count()); +} + +void DeferSpawn(const std::wstring& key, const coop::net::AtvSpawnPayload& payload, const char* why, + bool attempted) { + auto it = g_pendingSpawn.find(key); + if (it != g_pendingSpawn.end() && it->second.worldBound && + it->second.worldGen != ue_wrap::world_identity::Generation()) { + g_pendingSpawn.erase(it); + it = g_pendingSpawn.end(); + } + if (it == g_pendingSpawn.end()) { + if (g_pendingSpawn.size() >= kSpawnPendingCap) return; + it = g_pendingSpawn.emplace(key, PendingSpawn{payload, 0, 0}).first; + } + it->second.payload = payload; // the newest description of this ATV wins + PendingSpawn& p = it->second; + if (attempted && !p.worldBound) { + p.worldGen = ue_wrap::world_identity::Generation(); + p.worldBound = true; + } + if (attempted && ++p.tries > kSpawnTries) { + UE_LOGW("atv: runtime-ATV synthKey='%ls' still not spawned after %u tries (%s) -- given up", key.c_str(), + kSpawnTries, why); + g_pendingSpawn.erase(it); + return; + } + p.nextMs = SpawnNowMs() + kSpawnRetryMs; + if (attempted && p.tries == 1) + UE_LOGW("atv: runtime-ATV synthKey='%ls' not spawned yet (%s) -- retrying", key.c_str(), why); +} + +void RetryPendingSpawns() { + if (g_pendingSpawn.empty()) return; + // A row stamped for a world since left describes an ATV of that world: drop it; whatever + // still exists comes back through the new world's own announce and connect snapshot. + const uint32_t gen = ue_wrap::world_identity::Generation(); + for (auto it = g_pendingSpawn.begin(); it != g_pendingSpawn.end();) + it = it->second.worldBound && it->second.worldGen != gen ? g_pendingSpawn.erase(it) : std::next(it); + const uint64_t now = SpawnNowMs(); + std::vector due; + for (auto& kv : g_pendingSpawn) + if (now >= kv.second.nextMs) due.push_back(kv.second.payload); + for (const auto& p : due) OnAtvSpawn(p, 0); +} + +void DiscardSpawn(const std::wstring& key) { g_pendingSpawn.erase(key); } +void Clear() { g_pendingSpawn.clear(); } +} // namespace coop::atv_sync::spawn_retry diff --git a/src/votv-coop/src/coop/interactables/atv_spawn_retry.h b/src/votv-coop/src/coop/interactables/atv_spawn_retry.h new file mode 100644 index 000000000..6f83e3bc6 --- /dev/null +++ b/src/votv-coop/src/coop/interactables/atv_spawn_retry.h @@ -0,0 +1,11 @@ +// Bounded runtime-ATV retries; game thread, after the receiving-world readiness gate. +#pragma once +#include +namespace coop::net { struct AtvSpawnPayload; } +namespace coop::atv_sync::spawn_retry { +void DeferSpawn(const std::wstring& key, const coop::net::AtvSpawnPayload& payload, + const char* why, bool attempted = true); +void DiscardSpawn(const std::wstring& key); +void RetryPendingSpawns(); +void Clear(); +} diff --git a/src/votv-coop/src/coop/interactables/atv_sync.cpp b/src/votv-coop/src/coop/interactables/atv_sync.cpp index 7109f6618..a3b2dbb40 100644 --- a/src/votv-coop/src/coop/interactables/atv_sync.cpp +++ b/src/votv-coop/src/coop/interactables/atv_sync.cpp @@ -14,6 +14,7 @@ // tie-break, not authority. #include "coop/interactables/atv_sync.h" +#include "atv_spawn_retry.h" #include "coop/interactables/atv_corrector.h" #include "coop/dev/atv_eject_drill.h" #include "coop/interactables/atv_condition_sync.h" @@ -25,6 +26,7 @@ #include "coop/net/wire_key_util.h" // WireKeyFromString / StringFromWireKey / FnvKey (shared) #include "coop/player/players_registry.h" // Registry::Local / LocalPeerId / kMaxPeers #include "coop/player/roster_ledger.h" // SubscribeSlotReplaced: a departed author must not hold an ATV +#include "coop/session/world_load_episode.h" #include "ue_wrap/devices/atv.h" #include "ue_wrap/engine/engine.h" // ReadMainPlayerGrabState (grabber authority) + Get/SetActorRootPhysicsVelocity @@ -42,6 +44,7 @@ #include #include #include +#include #include namespace coop::atv_sync { @@ -498,25 +501,43 @@ void OnAtvRelease(const coop::net::AtvReleasePayload& payload, uint8_t senderPee void OnAtvSpawn(const coop::net::AtvSpawnPayload& payload, uint8_t /*senderPeerSlot*/) { auto* s = g_session.load(std::memory_order_acquire); - if (!s || s->role() == coop::net::Role::Host) return; // client-only (the host owns the real ATV) - if (!A::EnsureResolved()) return; + if (!s || !s->connected() || s->role() != coop::net::Role::Client) return; std::wstring synthKey = StringFromWireKey(payload.synthKey); if (synthKey.empty()) { UE_LOGW("atv: OnAtvSpawn empty synthKey -- dropping"); return; } - if (g_atvs.find(synthKey) != g_atvs.end()) return; // already spawned (re-announce / connect dup) -- idempotent + // The payload is checked before any wait: a malformed spawn is dropped, only a sound one is deferred. if (!std::isfinite(payload.x) || !std::isfinite(payload.y) || !std::isfinite(payload.z) || !std::isfinite(payload.pitch) || !std::isfinite(payload.yaw) || !std::isfinite(payload.roll)) { UE_LOGW("atv: OnAtvSpawn non-finite pose synthKey='%ls' -- dropping", synthKey.c_str()); + spawn_retry::DiscardSpawn(synthKey); // a malformed spawn is final, never retried return; } std::wstring className = WireClassNameToString(payload.className); - if (className.empty()) { UE_LOGW("atv: OnAtvSpawn empty className synthKey='%ls' -- dropping", synthKey.c_str()); return; } + if (className.empty()) { + UE_LOGW("atv: OnAtvSpawn empty className synthKey='%ls' -- dropping", synthKey.c_str()); + spawn_retry::DiscardSpawn(synthKey); + return; + } + if (ue_wrap::world_identity::CurrentWorldKind() != ue_wrap::world_identity::WorldKind::Gameplay || + !IndexCurrent() || !coop::world_load_episode::HasQuiesced()) { + spawn_retry::DeferSpawn(synthKey, payload, "receiving world not ready", false); + return; + } + if (!A::EnsureResolved()) { spawn_retry::DeferSpawn(synthKey, payload, "the ATV wrapper is not resolved"); return; } + auto known = g_atvs.find(synthKey); + if (known != g_atvs.end()) { + if (R::IsLiveByIndex(known->second.actor, known->second.idx)) { spawn_retry::DiscardSpawn(synthKey); return; } + if (known->second.actor) g_synthForActor.erase(known->second.actor); + g_atvs.erase(known); // a dead row is not "already spawned" + } const FVector loc{ payload.x, payload.y, payload.z }; const FRotator rot{ payload.pitch, payload.yaw, payload.roll }; void* spawned = A::SpawnMirror(className, loc, rot); // physics LEFT ON -- a native idle grabbable ATV if (!spawned) { UE_LOGW("atv: OnAtvSpawn SpawnMirror failed synthKey='%ls' class='%ls'", synthKey.c_str(), className.c_str()); + spawn_retry::DeferSpawn(synthKey, payload, "SpawnMirror failed"); return; } + spawn_retry::DiscardSpawn(synthKey); AtvEntry e{}; e.actor = spawned; e.idx = R::InternalIndexOf(spawned); @@ -532,6 +553,7 @@ void OnAtvDestroy(const coop::net::AtvDestroyPayload& payload, uint8_t /*senderP if (!s || s->role() == coop::net::Role::Host) return; // client-only std::wstring synthKey = StringFromWireKey(payload.synthKey); if (synthKey.empty()) return; + spawn_retry::DiscardSpawn(synthKey); // a destroy ends a spawn still waiting if (!IndexCurrent()) return; // the pass prunes a dead-world entry itself auto it = g_atvs.find(synthKey); if (it == g_atvs.end()) return; @@ -598,6 +620,11 @@ void Tick() { auto* s = g_session.load(std::memory_order_acquire); if (!s || !s->connected()) return; + // Deferred spawns retry only in the world the index just proved: a connected client inside + // Gameplay. An Unknown or loading world neither spawns nor spends an attempt. + if (s->role() != coop::net::Role::Host && + ue_wrap::world_identity::CurrentWorldKind() == ue_wrap::world_identity::WorldKind::Gameplay) + spawn_retry::RetryPendingSpawns(); void* localPlayer = coop::players::Registry::Get().Local(); const uint8_t localSlot = coop::players::Registry::Get().LocalPeerId(); const uint64_t nowMs = NowMs(); @@ -698,6 +725,7 @@ void Tick() { } void OnDisconnect() { + spawn_retry::Clear(); // Disarmed first: nothing publishes an owned set from here, and a live hit must reach the game. coop::atv_hit_guard::SetActive(false); coop::atv_hit_guard::PublishOwned(nullptr, 0); From e47271497a6022d6d754dbd6ec5da32198f56c54 Mon Sep 17 00:00:00 2001 From: pelmentor Date: Fri, 9 Oct 2026 23:31:09 +0600 Subject: [PATCH 019/306] [atv] a parked ATV spawn waits on readiness only, never on a count The adopted queue retried a spawn that failed in a ready world up to twenty times, half a second apart, then gave it up: a count standing in for a cause nobody had named. What the queue is for is a world not ready to take the spawn, and only that parks it now, stamped with the world it arrived in -- a world change drops it, the new world's replay bringing it again. A spawn the engine refuses in a ready world is final and said so; the park and its cap say when they hold or drop one. The module is renamed to what it is, atv_spawn_park. Co-Authored-By: Claude Opus 5.5 --- docs/code-map.md | 2 +- docs/vehicles.md | 5 +- src/votv-coop/sources.cmake | 2 +- .../src/coop/interactables/atv_spawn_park.cpp | 79 +++++++++++++++++++ .../src/coop/interactables/atv_spawn_park.h | 22 ++++++ .../coop/interactables/atv_spawn_retry.cpp | 79 ------------------- .../src/coop/interactables/atv_spawn_retry.h | 11 --- .../src/coop/interactables/atv_sync.cpp | 29 +++---- 8 files changed, 121 insertions(+), 108 deletions(-) create mode 100644 src/votv-coop/src/coop/interactables/atv_spawn_park.cpp create mode 100644 src/votv-coop/src/coop/interactables/atv_spawn_park.h delete mode 100644 src/votv-coop/src/coop/interactables/atv_spawn_retry.cpp delete mode 100644 src/votv-coop/src/coop/interactables/atv_spawn_retry.h diff --git a/docs/code-map.md b/docs/code-map.md index e8931273e..d737bab4a 100644 --- a/docs/code-map.md +++ b/docs/code-map.md @@ -25,7 +25,7 @@ One folder, one concept, named after it. `src/votv-coop/src/` holds the implemen | `coop/player/` | remote players and per-player state | `remote_player` + `remote_player_ragdoll`, `puppet_drive`, `puppet_carry_drive`, `puppet_body_yaw`, `puppet_footsteps` (driving the engine pawn), `players_registry`, `player_profile_store` (the host's copy of every player's profile: held in memory, cut to disk with the world save), `roster` + `roster_ledger` (who is in each slot), `nickname_arbiter`, `nick_color`, `nameplate`, `hand_item`, `local_body`, `local_streams`, `client_model` + `skin_registry` + `skin_preview` + `skin_effects` (skins), `movement_ledger`, `player_damage`, `death_revive` + `run_end_travel` (a trip to the menu the player did not ask for is refused and answered with a revive), `ragdoll_gate`, `sleep_sync`, `item_activate`, `flashlight_click_sound`, `stat_orders` (an admin's order to, and query of, one player's stats and effects) + `stat_orders_wire` (their bytes, their checks, and the table of the ones awaiting an answer) | | `coop/props/` | physics props, piles and trash | `prop_lifecycle` (the destroy seam), `prop_snapshot` + `snapshot_census` (the connect snapshot), `remote_prop` with `_spawn`, `_convert`, `_physics`, `_destroy` (the receivers), `host_spawn_watcher`, `prop_key_index`, `prop_synth_key`, `save_identity_bind` + `save_identity_map` (bind save-loaded actors to host eids in the join window), `pile_spawn_bind`, `trash_mirror` (the client's mirror of either trash form, the game's own actor parked), `trash_morph_gate` (the verbs a client refuses so it never authors a transition), `trash_sweep` (the clumps a broom stroke sets rolling: their ids at birth, a carry nobody holds, the roll), `trash_channel`, `trash_grab_intent`, `trash_collect_sync`, `trash_pile_sync`, `trash_use_intercept`, `trash_clump_pose_stream`, `prop_drop_intent`, `prop_spawn_authoring` (did a PLAYER's spawn verb author this birth, or the world's own churn -- the fact the drop-intent seam admits a catalog spawn on), `prop_save_data` (each prop's own save record, by Key), `prop_stick_sync`, `container_contents_sync` + `container_write_policy` (a world container slice, and the host arbitration a client-authored one passes) + `container_park` (the holding pen for a slice whose container has not arrived yet) + `container_view_close` (a player's view into a container ends where the player's reach does), `grab_observer`, `registry_reaper`, `join_membership_sweep`, `unresolved_pose_ledger`, `prop_echo_suppress`, `prop_wire_parity`, `prop_census`, `prop_container_extract`, `prop_destroy_seam`, `prop_fresh_spawn`, `prop_element_tracker`, `prop_sound`, `active_drive` | | `coop/creatures/` | NPCs and creatures | `npc_sync` + `npc_sync_install` (the spawn seam), `npc_mirror`, `npc_adoption`, `npc_pose_host`, `npc_pose_drive`, `npc_world_enum`, `kerfur_convert` with `_host` and `_client`, `kerfur_entity`, `kerfur_reconcile`, `kerfur_prop_adoption`, `kerfur_command`, `kerfur_menu_input`, `kerfur_form_assembler` (the prop-to-NPC conversion cycle), `owner_entity_sync` (creatures whose AI reads the local player: owned per peer, visible to all), `piramid_sync`, `roach_sync`, `wisp_attack_sync`, `wisp_grab_hold`, `wisp_tear_mirror` | -| `coop/interactables/` | keyed device state | doors, lights, light groups, containers, garage, appliances, lockers: `interactable_sync` + `interactable_channel`; `keypad_sync` + `keypad_verbs`, `verb_lanes` (the lanes on a device's own verbs at the script gate, wired in one place), `toggle_verbs` (a symmetric device's state at the verb that writes it), `window_sync`, `grime_sync`, `turbine_sync`, `drone_sync`, `device_occupancy`, `garbage_sync`. The signal workstation: `signal_sync`, `signal_catch_sync`, `signal_wire`, `dish_sync`, `dish_calib_sync`, `download_arm_sync` (the download's arm and reset, the host's), `desk_input_sync`, `desk_sim_sync`, `desk_ping_sync` (the ping's verdict, rolled on the host), `desk_verb_intent` and `desk_verb_effects` (the desk's save-family presses, run by the host, their gloss, stat and sounds sent to the presser), `desk_cursor_sync`, `desk_snd_fx`, `deck_play_sync`, `comp_sync` (the refiner's decode, the host's), `console_state_sync`, `drive_sync`, `drive_payload_sync` (a drive's row, the host's), `eraser_press_intent` (the eraser's delete, run by the host), `drive_rack_sync`, `tape_caddy_sync`, `physmods_sync`, `serverbox_sync`, `server_upgrade_sync` (a server box's physical upgrades), `sat_console_sync` and `sat_console_table` (the SAT console's shared commands, run on a terminal the host keeps for each typist, and which command runs where), `laptop_sync`, `portable_pc_lid` (the portable PC's lid, on its own verb), `laptop_buffer_sync`, `floppybox_sync`, `floppy_slot_sync` and `floppy_slot_entry` (a device's disc slot: the state, and which overlap into it counts as an insert), `meadow_db_sync`, `meadow_db_writers`, `meadow_db_join`, `meadow_db_park` and `meadow_db_hash` (the laptop's signal database, the bodies that write it, a joiner's seed of it, its inbound lines that cannot apply yet, and the database as content hashes). The ATV: `atv_sync`, `atv_spawn_retry` (bounded runtime births), `atv_corrector`, `atv_hit_guard`, `atv_condition_sync` | +| `coop/interactables/` | keyed device state | doors, lights, light groups, containers, garage, appliances, lockers: `interactable_sync` + `interactable_channel`; `keypad_sync` + `keypad_verbs`, `verb_lanes` (the lanes on a device's own verbs at the script gate, wired in one place), `toggle_verbs` (a symmetric device's state at the verb that writes it), `window_sync`, `grime_sync`, `turbine_sync`, `drone_sync`, `device_occupancy`, `garbage_sync`. The signal workstation: `signal_sync`, `signal_catch_sync`, `signal_wire`, `dish_sync`, `dish_calib_sync`, `download_arm_sync` (the download's arm and reset, the host's), `desk_input_sync`, `desk_sim_sync`, `desk_ping_sync` (the ping's verdict, rolled on the host), `desk_verb_intent` and `desk_verb_effects` (the desk's save-family presses, run by the host, their gloss, stat and sounds sent to the presser), `desk_cursor_sync`, `desk_snd_fx`, `deck_play_sync`, `comp_sync` (the refiner's decode, the host's), `console_state_sync`, `drive_sync`, `drive_payload_sync` (a drive's row, the host's), `eraser_press_intent` (the eraser's delete, run by the host), `drive_rack_sync`, `tape_caddy_sync`, `physmods_sync`, `serverbox_sync`, `server_upgrade_sync` (a server box's physical upgrades), `sat_console_sync` and `sat_console_table` (the SAT console's shared commands, run on a terminal the host keeps for each typist, and which command runs where), `laptop_sync`, `portable_pc_lid` (the portable PC's lid, on its own verb), `laptop_buffer_sync`, `floppybox_sync`, `floppy_slot_sync` and `floppy_slot_entry` (a device's disc slot: the state, and which overlap into it counts as an insert), `meadow_db_sync`, `meadow_db_writers`, `meadow_db_join`, `meadow_db_park` and `meadow_db_hash` (the laptop's signal database, the bodies that write it, a joiner's seed of it, its inbound lines that cannot apply yet, and the database as content hashes). The ATV: `atv_sync`, `atv_spawn_park` (a runtime ATV's spawn held until its world is ready), `atv_corrector`, `atv_hit_guard`, `atv_condition_sync` | | `coop/world/` | global and ambient state | `time_sync`, `sky_sync`, `weather_sync` with `weather_rain`, `weather_fog`, `weather_lightning`, `weather_redsky`, `weather_event_births`, `firefly_sync`, `spawn_authority` (shared-world spawners run on the host only), `event_fire_sync`, `event_active_sync`, `event_cue_sync`, `alarm_sync`, `balance_sync`, `email_sync`, `daily_task_sync`, `power_grid` (the generators run on the host) + `power_decay` (their wear dice) + `power_puzzle` (their repair puzzles) + `power_upgrade` (the upgrades an install spent) + `power_panel` (the panel's one author), `world_actor_sync` + `world_actor_mirror` | | `coop/items/` | inventory, the economy, and the deployed items | `player_inventory_sync` + `inventory_wire`, `inventory_pickup_sync`, `order_sync` (shop orders: the client names the row, the host performs and prices it) + `order_queue_sync` (the host's delivery queue mirrored on the clients) + `order_rows` (an order's items on the wire), `coingun_sync` + `coingun_arbiter` + `coingun_collect` (the sell gun and its coins), `save_record_wire`, `hook_sync` + `hook_anchor` + `hook_prop_claim` + `hook_constraint` (the grappling hook and the rope: the owner's stream and the mirrors, the anchor handoff, the props its ties claim, and the tie itself, which lives on the host), `broom_stroke` + `broom_push` (the broom: every stroke is the host's, a client's queued with what it read of its holder, and what a stroke pushes or knocks out of a dispenser streams) | | `coop/comms/` | text chat | `chat_sync`, `chat_feed`, `chat_bubbles`, `chat_log`, `chat_nick_color`, `peer_action_feed` | diff --git a/docs/vehicles.md b/docs/vehicles.md index c5ec2368a..d54471ef7 100644 --- a/docs/vehicles.md +++ b/docs/vehicles.md @@ -48,8 +48,9 @@ synced by the host at five hertz gated on change with a two-second keepalive flo vehicle costs one packet every two seconds. An ATV that appears at runtime, from the spawn menu, has no save twin, so the host mints a synthetic key and announces it. The client parks a valid announcement until gameplay, the discovery index and load quiescence -agree; loading spends no spawn attempt. Only an attempted spawn binds the pending row to a -world generation. `[?]` This readiness path still needs a two-peer join and travel test. +agree, stamped with the world it arrived in: a world change drops it, the new world's own replay +bringing it again, and a spawn that fails in a ready world is final, never retried. +`[?]` This readiness path still needs a two-peer join and travel test. ### Condition diff --git a/src/votv-coop/sources.cmake b/src/votv-coop/sources.cmake index 1e4ac6b11..a4657b8bd 100644 --- a/src/votv-coop/sources.cmake +++ b/src/votv-coop/sources.cmake @@ -223,7 +223,7 @@ set(VOTVCOOP_SOURCES src/coop/world/coord_tower_ops.cpp src/coop/world/coord_tower_rows.cpp src/coop/interactables/atv_sync.cpp - src/coop/interactables/atv_spawn_retry.cpp + src/coop/interactables/atv_spawn_park.cpp src/coop/interactables/atv_condition_sync.cpp src/coop/interactables/atv_corrector.cpp src/coop/interactables/atv_hit_guard.cpp diff --git a/src/votv-coop/src/coop/interactables/atv_spawn_park.cpp b/src/votv-coop/src/coop/interactables/atv_spawn_park.cpp new file mode 100644 index 000000000..3393ea13a --- /dev/null +++ b/src/votv-coop/src/coop/interactables/atv_spawn_park.cpp @@ -0,0 +1,79 @@ +// coop/interactables/atv_spawn_park.cpp -- see coop/interactables/atv_spawn_park.h. +#include "atv_spawn_park.h" +#include "coop/interactables/atv_sync.h" +#include "coop/net/protocol.h" +#include "ue_wrap/core/hot_path_guard.h" // UE_ASSERT_GAME_THREAD +#include "ue_wrap/core/log.h" +#include "ue_wrap/engine/world_identity.h" +#include +#include +#include + +namespace coop::atv_sync::spawn_park { +namespace { +struct Parked { + coop::net::AtvSpawnPayload payload{}; + uint32_t worldGen = 0; // the world it arrived in; another world drops it +}; +std::unordered_map g_parked; +// The host runs a handful of runtime ATVs; a park past this describes no world of theirs. +constexpr size_t kCap = 16; +constexpr uint64_t kPollMs = 500; +uint64_t g_nextPollMs = 0; +bool g_saidCap = false; + +uint64_t NowMs() { + return static_cast(std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()).count()); +} +} // namespace + +void Park(const std::wstring& key, const coop::net::AtvSpawnPayload& payload, const char* why) { + UE_ASSERT_GAME_THREAD("atv_spawn_park::Park"); + const uint32_t gen = ue_wrap::world_identity::Generation(); + auto it = g_parked.find(key); + if (it == g_parked.end()) { + if (g_parked.size() >= kCap) { + if (!g_saidCap) { + g_saidCap = true; + UE_LOGW("atv: %zu runtime-ATV spawns already parked -- synthKey='%ls' (%s) dropped; the next " + "world-ready replay sends it again (said once)", kCap, key.c_str(), why); + } + return; + } + UE_LOGI("atv: runtime-ATV synthKey='%ls' parked until this world is ready (%s)", key.c_str(), why); + g_parked.emplace(key, Parked{payload, gen}); + return; + } + it->second.payload = payload; // the newest description of this ATV wins + it->second.worldGen = gen; +} + +void Discard(const std::wstring& key) { g_parked.erase(key); } + +void DrainReady() { + UE_ASSERT_GAME_THREAD("atv_spawn_park::DrainReady"); + if (g_parked.empty()) return; + const uint64_t now = NowMs(); + if (now < g_nextPollMs) return; + g_nextPollMs = now + kPollMs; + const uint32_t gen = ue_wrap::world_identity::Generation(); + std::vector ready; + for (auto it = g_parked.begin(); it != g_parked.end();) { + if (it->second.worldGen != gen) { + UE_LOGI("atv: a parked runtime-ATV spawn of a world since left dropped -- this world's replay sends it"); + it = g_parked.erase(it); + continue; + } + ready.push_back(it->second.payload); + ++it; + } + for (const auto& p : ready) OnAtvSpawn(p, 0); +} + +void Clear() { + g_parked.clear(); + g_nextPollMs = 0; + g_saidCap = false; +} +} // namespace coop::atv_sync::spawn_park diff --git a/src/votv-coop/src/coop/interactables/atv_spawn_park.h b/src/votv-coop/src/coop/interactables/atv_spawn_park.h new file mode 100644 index 000000000..d838964d7 --- /dev/null +++ b/src/votv-coop/src/coop/interactables/atv_spawn_park.h @@ -0,0 +1,22 @@ +// coop/interactables/atv_spawn_park.h -- a runtime ATV's spawn, held until this client's world can take it. +// +// The host sends an ATV's spawn only to a world-ready slot and re-sends every runtime ATV at each world-ready +// announce, so a spawn arriving in a world since left is answered by that replay. What the replay cannot answer is a +// spawn landing in THIS world while it is not ready for it -- a post-snapshot sweep re-arming the load probe, the ATV +// wrapper not resolved yet -- which would otherwise be dropped for good, the pose stream only moving an ATV that +// exists. Such a spawn is parked here, stamped with the world it arrived in, and handed back once the world reads +// ready; a world change drops it, the new world's replay bringing it again. A spawn that fails once its world is +// ready is final: nothing here retries it. Game thread. +#pragma once +#include +namespace coop::net { struct AtvSpawnPayload; } +namespace coop::atv_sync::spawn_park { +// Hold the newest spawn for this key until the world is ready; `why` names the readiness that was missing. +void Park(const std::wstring& key, const coop::net::AtvSpawnPayload& payload, const char* why); +// A spawn that landed, was malformed, or whose ATV was destroyed: nothing for it waits any longer. +void Discard(const std::wstring& key); +// Tick, inside a gameplay world: hand each parked spawn back to OnAtvSpawn, which parks it again while the world is +// still not ready. A half-second poll of that readiness. +void DrainReady(); +void Clear(); +} // namespace coop::atv_sync::spawn_park diff --git a/src/votv-coop/src/coop/interactables/atv_spawn_retry.cpp b/src/votv-coop/src/coop/interactables/atv_spawn_retry.cpp deleted file mode 100644 index 6eaae0e13..000000000 --- a/src/votv-coop/src/coop/interactables/atv_spawn_retry.cpp +++ /dev/null @@ -1,79 +0,0 @@ -// coop/interactables/atv_spawn_retry.cpp -- bounded runtime-ATV spawn queue. -#include "atv_spawn_retry.h" -#include "coop/interactables/atv_sync.h" -#include "coop/net/protocol.h" -#include "ue_wrap/core/log.h" -#include "ue_wrap/engine/world_identity.h" -#include -#include -#include -#include - -namespace coop::atv_sync::spawn_retry { -// A spawn that could not run yet (the wrapper unresolved, or the spawn itself failed) waits here and is retried, so a -// transient miss does not leave the ATV missing until a rejoin: the pose stream only moves an ATV that exists. A -// destroy cancels the wait; a spawn that lands, the budget running out, or the world leaving the stamped generation -// ends it. -struct PendingSpawn { - coop::net::AtvSpawnPayload payload{}; - uint32_t tries = 0; - uint64_t nextMs = 0; - uint32_t worldGen = 0; - bool worldBound = false; // bind only when the receiving world is ready -}; -std::unordered_map g_pendingSpawn; -constexpr uint32_t kSpawnTries = 20; -constexpr uint64_t kSpawnRetryMs = 500; -constexpr size_t kSpawnPendingCap = 16; - -uint64_t SpawnNowMs() { - return static_cast(std::chrono::duration_cast( - std::chrono::steady_clock::now().time_since_epoch()).count()); -} - -void DeferSpawn(const std::wstring& key, const coop::net::AtvSpawnPayload& payload, const char* why, - bool attempted) { - auto it = g_pendingSpawn.find(key); - if (it != g_pendingSpawn.end() && it->second.worldBound && - it->second.worldGen != ue_wrap::world_identity::Generation()) { - g_pendingSpawn.erase(it); - it = g_pendingSpawn.end(); - } - if (it == g_pendingSpawn.end()) { - if (g_pendingSpawn.size() >= kSpawnPendingCap) return; - it = g_pendingSpawn.emplace(key, PendingSpawn{payload, 0, 0}).first; - } - it->second.payload = payload; // the newest description of this ATV wins - PendingSpawn& p = it->second; - if (attempted && !p.worldBound) { - p.worldGen = ue_wrap::world_identity::Generation(); - p.worldBound = true; - } - if (attempted && ++p.tries > kSpawnTries) { - UE_LOGW("atv: runtime-ATV synthKey='%ls' still not spawned after %u tries (%s) -- given up", key.c_str(), - kSpawnTries, why); - g_pendingSpawn.erase(it); - return; - } - p.nextMs = SpawnNowMs() + kSpawnRetryMs; - if (attempted && p.tries == 1) - UE_LOGW("atv: runtime-ATV synthKey='%ls' not spawned yet (%s) -- retrying", key.c_str(), why); -} - -void RetryPendingSpawns() { - if (g_pendingSpawn.empty()) return; - // A row stamped for a world since left describes an ATV of that world: drop it; whatever - // still exists comes back through the new world's own announce and connect snapshot. - const uint32_t gen = ue_wrap::world_identity::Generation(); - for (auto it = g_pendingSpawn.begin(); it != g_pendingSpawn.end();) - it = it->second.worldBound && it->second.worldGen != gen ? g_pendingSpawn.erase(it) : std::next(it); - const uint64_t now = SpawnNowMs(); - std::vector due; - for (auto& kv : g_pendingSpawn) - if (now >= kv.second.nextMs) due.push_back(kv.second.payload); - for (const auto& p : due) OnAtvSpawn(p, 0); -} - -void DiscardSpawn(const std::wstring& key) { g_pendingSpawn.erase(key); } -void Clear() { g_pendingSpawn.clear(); } -} // namespace coop::atv_sync::spawn_retry diff --git a/src/votv-coop/src/coop/interactables/atv_spawn_retry.h b/src/votv-coop/src/coop/interactables/atv_spawn_retry.h deleted file mode 100644 index 6f83e3bc6..000000000 --- a/src/votv-coop/src/coop/interactables/atv_spawn_retry.h +++ /dev/null @@ -1,11 +0,0 @@ -// Bounded runtime-ATV retries; game thread, after the receiving-world readiness gate. -#pragma once -#include -namespace coop::net { struct AtvSpawnPayload; } -namespace coop::atv_sync::spawn_retry { -void DeferSpawn(const std::wstring& key, const coop::net::AtvSpawnPayload& payload, - const char* why, bool attempted = true); -void DiscardSpawn(const std::wstring& key); -void RetryPendingSpawns(); -void Clear(); -} diff --git a/src/votv-coop/src/coop/interactables/atv_sync.cpp b/src/votv-coop/src/coop/interactables/atv_sync.cpp index a3b2dbb40..e51973bf4 100644 --- a/src/votv-coop/src/coop/interactables/atv_sync.cpp +++ b/src/votv-coop/src/coop/interactables/atv_sync.cpp @@ -14,7 +14,7 @@ // tie-break, not authority. #include "coop/interactables/atv_sync.h" -#include "atv_spawn_retry.h" +#include "atv_spawn_park.h" #include "coop/interactables/atv_corrector.h" #include "coop/dev/atv_eject_drill.h" #include "coop/interactables/atv_condition_sync.h" @@ -508,24 +508,24 @@ void OnAtvSpawn(const coop::net::AtvSpawnPayload& payload, uint8_t /*senderPeerS if (!std::isfinite(payload.x) || !std::isfinite(payload.y) || !std::isfinite(payload.z) || !std::isfinite(payload.pitch) || !std::isfinite(payload.yaw) || !std::isfinite(payload.roll)) { UE_LOGW("atv: OnAtvSpawn non-finite pose synthKey='%ls' -- dropping", synthKey.c_str()); - spawn_retry::DiscardSpawn(synthKey); // a malformed spawn is final, never retried + spawn_park::Discard(synthKey); // a malformed spawn is final return; } std::wstring className = WireClassNameToString(payload.className); if (className.empty()) { UE_LOGW("atv: OnAtvSpawn empty className synthKey='%ls' -- dropping", synthKey.c_str()); - spawn_retry::DiscardSpawn(synthKey); + spawn_park::Discard(synthKey); return; } if (ue_wrap::world_identity::CurrentWorldKind() != ue_wrap::world_identity::WorldKind::Gameplay || !IndexCurrent() || !coop::world_load_episode::HasQuiesced()) { - spawn_retry::DeferSpawn(synthKey, payload, "receiving world not ready", false); + spawn_park::Park(synthKey, payload, "the receiving world is not ready"); return; } - if (!A::EnsureResolved()) { spawn_retry::DeferSpawn(synthKey, payload, "the ATV wrapper is not resolved"); return; } + if (!A::EnsureResolved()) { spawn_park::Park(synthKey, payload, "the ATV wrapper is not resolved"); return; } auto known = g_atvs.find(synthKey); if (known != g_atvs.end()) { - if (R::IsLiveByIndex(known->second.actor, known->second.idx)) { spawn_retry::DiscardSpawn(synthKey); return; } + if (R::IsLiveByIndex(known->second.actor, known->second.idx)) { spawn_park::Discard(synthKey); return; } if (known->second.actor) g_synthForActor.erase(known->second.actor); g_atvs.erase(known); // a dead row is not "already spawned" } @@ -533,11 +533,13 @@ void OnAtvSpawn(const coop::net::AtvSpawnPayload& payload, uint8_t /*senderPeerS const FRotator rot{ payload.pitch, payload.yaw, payload.roll }; void* spawned = A::SpawnMirror(className, loc, rot); // physics LEFT ON -- a native idle grabbable ATV if (!spawned) { - UE_LOGW("atv: OnAtvSpawn SpawnMirror failed synthKey='%ls' class='%ls'", synthKey.c_str(), className.c_str()); - spawn_retry::DeferSpawn(synthKey, payload, "SpawnMirror failed"); + // In a world that reads ready, a refused spawn is the class or the engine saying no: final, not retried. + UE_LOGW("atv: OnAtvSpawn SpawnMirror failed synthKey='%ls' class='%ls' -- not retried", synthKey.c_str(), + className.c_str()); + spawn_park::Discard(synthKey); return; } - spawn_retry::DiscardSpawn(synthKey); + spawn_park::Discard(synthKey); AtvEntry e{}; e.actor = spawned; e.idx = R::InternalIndexOf(spawned); @@ -553,7 +555,7 @@ void OnAtvDestroy(const coop::net::AtvDestroyPayload& payload, uint8_t /*senderP if (!s || s->role() == coop::net::Role::Host) return; // client-only std::wstring synthKey = StringFromWireKey(payload.synthKey); if (synthKey.empty()) return; - spawn_retry::DiscardSpawn(synthKey); // a destroy ends a spawn still waiting + spawn_park::Discard(synthKey); // a destroy ends a spawn still waiting if (!IndexCurrent()) return; // the pass prunes a dead-world entry itself auto it = g_atvs.find(synthKey); if (it == g_atvs.end()) return; @@ -620,11 +622,10 @@ void Tick() { auto* s = g_session.load(std::memory_order_acquire); if (!s || !s->connected()) return; - // Deferred spawns retry only in the world the index just proved: a connected client inside - // Gameplay. An Unknown or loading world neither spawns nor spends an attempt. + // Parked spawns go back to OnAtvSpawn only inside a gameplay world, which re-judges the rest of its readiness. if (s->role() != coop::net::Role::Host && ue_wrap::world_identity::CurrentWorldKind() == ue_wrap::world_identity::WorldKind::Gameplay) - spawn_retry::RetryPendingSpawns(); + spawn_park::DrainReady(); void* localPlayer = coop::players::Registry::Get().Local(); const uint8_t localSlot = coop::players::Registry::Get().LocalPeerId(); const uint64_t nowMs = NowMs(); @@ -725,7 +726,7 @@ void Tick() { } void OnDisconnect() { - spawn_retry::Clear(); + spawn_park::Clear(); // Disarmed first: nothing publishes an owned set from here, and a live hit must reach the game. coop::atv_hit_guard::SetActive(false); coop::atv_hit_guard::PublishOwned(nullptr, 0); From 4f8df6d1c07cc982f5febf85918564661854b9dc Mon Sep 17 00:00:00 2001 From: Shinobu2 <32747633+Shinobu2@users.noreply.github.com> Date: Wed, 7 Oct 2026 11:50:47 -0700 Subject: [PATCH 020/306] [ue_wrap] a second game process on one install gets a log of its own A second process on the same install could not rename the first one's live log, whose open denied write sharing, and then failed to open it, so it wrote no log at all and a bug report from it attached the other process's file. The log now opens with read sharing only, so a live writer still refuses the rename; the second process falls back to multivoid..log, and the previous-run path is set only when this process really rotated a closed run's log. The net of two commits below: the first opened with delete sharing, which the second reverses. Co-Authored-By: Claude Opus 5.5 Co-Authored-By: Codex (cherry picked from commit 160e4ecafce8eed1478c786ef1ff894fe57f4cb9) (cherry picked from commit e3b14fe2fed153035879a84a8acb2db86ab2e0ea) --- src/votv-coop/src/ue_wrap/core/log.cpp | 45 +++++++++++++++++++------- 1 file changed, 34 insertions(+), 11 deletions(-) diff --git a/src/votv-coop/src/ue_wrap/core/log.cpp b/src/votv-coop/src/ue_wrap/core/log.cpp index d372baf1e..055bf67d2 100644 --- a/src/votv-coop/src/ue_wrap/core/log.cpp +++ b/src/votv-coop/src/ue_wrap/core/log.cpp @@ -7,6 +7,8 @@ #include #include #include +#include +#include #include #include #include @@ -66,6 +68,19 @@ std::atomic g_sink{nullptr}; // set, else multivoid.log (per-process names for multi-instance tests). Anchored on the exe // directory, the install's one real home; the DLL's own directory is loader-dependent and // virtualised under the shim loader. + +// Readers may tail the live log; other processes must not rename or truncate it. +FILE* OpenShared(const wchar_t* path) { + HANDLE h = ::CreateFileW(path, GENERIC_WRITE, FILE_SHARE_READ, nullptr, CREATE_ALWAYS, + FILE_ATTRIBUTE_NORMAL, nullptr); + if (h == INVALID_HANDLE_VALUE) return nullptr; + const int fd = _open_osfhandle(reinterpret_cast(h), _O_WRONLY | _O_TEXT); + if (fd < 0) { ::CloseHandle(h); return nullptr; } + FILE* f = _fdopen(fd, "w"); + if (!f) _close(fd); + return f; +} + void LogPath(wchar_t (&out)[MAX_PATH]) { out[0] = L'\0'; const std::wstring dir = paths::ExeDir(); @@ -88,11 +103,7 @@ void EnsureOpen() { if (!g_opened) { wchar_t path[MAX_PATH] = {}; LogPath(path); - wcscpy_s(g_livePath, path); - // Preserve the previous session's log before the open below truncates it: players hit a - // problem, then often relaunch before sending the log, and one level of history means the - // bug session survives that relaunch. The prior process has exited (each launch is a fresh - // process), so the rename is safe. + // Preserve a closed run's log. A live writer denies rename and forces the PID fallback. { wchar_t prev[MAX_PATH] = {}; wcscpy_s(prev, path); @@ -103,13 +114,25 @@ void EnsureOpen() { } else { wcscat_s(prev, L".prev"); } - wcscpy_s(g_prevPath, prev); - ::MoveFileExW(path, prev, MOVEFILE_REPLACE_EXISTING); // best-effort; ignore failure + if (::MoveFileExW(path, prev, MOVEFILE_REPLACE_EXISTING)) wcscpy_s(g_prevPath, prev); + g_file = OpenShared(path); + if (g_file) wcscpy_s(g_livePath, path); + } + // This also covers an overlapping older build that does not share delete access. + if (!g_file) { + g_prevPath[0] = L'\0'; + wchar_t alt[MAX_PATH]; + wcscpy_s(alt, path); + const size_t alen = wcslen(alt); + if (alen > 4 && _wcsicmp(alt + alen - 4, L".log") == 0) alt[alen - 4] = L'\0'; + wchar_t suffix[32]; + swprintf_s(suffix, L".%lu.log", static_cast(::GetCurrentProcessId())); + wcscat_s(alt, suffix); + g_file = OpenShared(alt); + if (g_file) { + wcscpy_s(g_livePath, alt); + } } - // Open with read sharing (others may read, not write), so the log can be tailed live while - // the game runs; without it the file is locked exclusively and diagnostics cannot be read - // until the game exits. - g_file = _wfsopen(path, L"w", _SH_DENYWR); if (g_file) { std::fprintf(g_file, "==== Multivoid log ====\n"); std::fprintf(g_file, "%s\n", kLogFormatLine); From 1ab56061089858b8543be90858686ace54ad5abf Mon Sep 17 00:00:00 2001 From: pelmentor Date: Fri, 9 Oct 2026 23:41:26 +0600 Subject: [PATCH 021/306] [ue_wrap] the log's path contract says when it is a PID file CurrentPath and PreviousPath still read as if every run rotated the last one: the live log can now be the PID-named fallback of a process that overlapped another on one install, and then the previous path is empty, since nothing was rotated. The header says so, and that a fallback file is never rotated or removed. The share.h include the old open needed goes. Co-Authored-By: Claude Opus 5.5 --- src/votv-coop/include/ue_wrap/core/log.h | 9 ++++++--- src/votv-coop/src/ue_wrap/core/log.cpp | 1 - 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/votv-coop/include/ue_wrap/core/log.h b/src/votv-coop/include/ue_wrap/core/log.h index e41fdfbc6..ef23c2cd5 100644 --- a/src/votv-coop/include/ue_wrap/core/log.h +++ b/src/votv-coop/include/ue_wrap/core/log.h @@ -45,9 +45,12 @@ void SetSink(Sink sink); void Flush(); // The full paths of the live log and of the file the previous run's log was kept as (the live -// name with `.prev.log` for `.log`, `.prev` appended otherwise). The rig names its logs by the -// VOTVCOOP_LOG environment value, so no reader may hardcode `multivoid.log`: it asks here. Opens -// the log first, so the answer is the real one. Any thread. +// name with `.prev.log` for `.log`, `.prev` appended otherwise). The live log is the PID-named +// fallback (`multivoid..log`) when another process of this install still writes the usual +// name, and then nothing was rotated: the previous path is empty unless THIS process kept a closed +// run's log. A fallback file is never rotated or removed; each one is an overlapped launch's whole +// log. The rig names its logs by the VOTVCOOP_LOG environment value, so no reader may hardcode +// `multivoid.log`: it asks here. Opens the log first, so the answer is the real one. Any thread. std::wstring CurrentPath(); std::wstring PreviousPath(); diff --git a/src/votv-coop/src/ue_wrap/core/log.cpp b/src/votv-coop/src/ue_wrap/core/log.cpp index 055bf67d2..4de519237 100644 --- a/src/votv-coop/src/ue_wrap/core/log.cpp +++ b/src/votv-coop/src/ue_wrap/core/log.cpp @@ -16,7 +16,6 @@ #include #include #include -#include namespace ue_wrap::log { namespace { From e68998a0a98f14d79c166d7427894f52a814c5f3 Mon Sep 17 00:00:00 2001 From: Shinobu2 Date: Mon, 5 Oct 2026 15:55:32 -0700 Subject: [PATCH 022/306] [coop] the host pays a client's server repair with the fix The minigame credited its 15/50 and its record 30/100 on the client only, and the host's balance row then took it away. A client now refuses the widget's own credit and its repair intent carries the lol mode and the solve time in the former pad bytes; the host pays from its own box and saveSlot best time in the same step as the fix. A refused repair pays nothing; a sender without the fields gets the fix only. Build 218: the pad bytes now carry the reward fields. Co-Authored-By: Claude Opus 5.5 (cherry picked from commit c42870d50fd66ba2809f6d8e813e2521f02ddcdf) --- docs/devices.md | 6 +- src/votv-coop/include/coop/net/protocol.h | 13 ++- .../src/coop/interactables/serverbox_sync.cpp | 93 +++++++++++++++++-- 3 files changed, 99 insertions(+), 13 deletions(-) diff --git a/docs/devices.md b/docs/devices.md index 912b23f11..d34c8fcbf 100644 --- a/docs/devices.md +++ b/docs/devices.md @@ -419,7 +419,11 @@ refuses every one of them through its whole connected session, since each break the host's own game makes, and its player's own repair -- the fix the gamemode's repair widget calls when the minigame succeeds -- goes to the host as an intent, which the host runs on its box when it is broken and within the player's reach, answering a refusal with its state to that player -alone; the widget's points and stats stay the player's. The host polls each box's broken and +alone. The widget's points are the shared balance's, so a client's own credit from the widget is +refused and the intent carries what the reward reads (the lol mode and the solve time): the host +pays the repair (15, or 50 in lol mode, nothing for a box broken by damage) and a record under its +own best time (30, or 100) in the same step as the fix, and a repair it refuses pays nothing. The +widget's stats stay the player's. The host polls each box's broken and damaged flags, the repair type its break rolled, and the three totals the gamemode keeps for the farm, broadcasts a change at once after a repair and within a second otherwise, and a client writes the flags and the type and calls the box's own re-skin, which is notify-free and so repaints diff --git a/src/votv-coop/include/coop/net/protocol.h b/src/votv-coop/include/coop/net/protocol.h index 2c211bf77..eb1ab54e8 100644 --- a/src/votv-coop/include/coop/net/protocol.h +++ b/src/votv-coop/include/coop/net/protocol.h @@ -30,7 +30,7 @@ inline constexpr uint32_t kMagic = 0x564D5450u; // This file is past the 1500-line hard cap and stays there: it is the single-feature exception the // rule names. One wire format, whose enum, payload structs and static_asserts are read together; // splitting it would put a kind's number in one file and its bytes in another. -inline constexpr uint16_t kProtocolVersion = 217; +inline constexpr uint16_t kProtocolVersion = 218; // Default LAN port (overridable via multivoid.ini "net.port="). inline constexpr uint16_t kDefaultPort = 47621; @@ -1812,10 +1812,15 @@ struct ServerStatePayload { }; static_assert(sizeof(ServerStatePayload) == 96, "ServerStatePayload must be 96 bytes"); -// A player's finished repair (ServerRepair), client to host: the box by its servers[] index. +// A player's finished repair (ServerRepair), client to host: the box by its servers[] index, and what the +// minigame's own reward reads -- its lol mode and the solve time -- so the host pays it in the same step as +// the fix. A sender without the reward fields leaves the bytes zero and kRepairSettles clear: fix only. +inline constexpr uint8_t kRepairSettles = 0x01; // flags: the reward fields are filled +inline constexpr uint8_t kRepairLol = 0x02; // flags: the widget ran in lol mode (the larger rewards) struct ServerRepairPayload { - uint8_t box; // 1 -- servers[] index - uint8_t _pad[3]; // 3 -- zeroed + uint8_t box; // 1 -- servers[] index + uint8_t flags; // 1 -- kRepair* bits + uint16_t timeDs; // 2 -- the solve time in tenths of a second, saturated; 0 = none }; static_assert(sizeof(ServerRepairPayload) == 4, "ServerRepairPayload must be 4 bytes"); diff --git a/src/votv-coop/src/coop/interactables/serverbox_sync.cpp b/src/votv-coop/src/coop/interactables/serverbox_sync.cpp index 40f97907b..edd197ea9 100644 --- a/src/votv-coop/src/coop/interactables/serverbox_sync.cpp +++ b/src/votv-coop/src/coop/interactables/serverbox_sync.cpp @@ -17,6 +17,7 @@ #include "ue_wrap/core/reflection.h" #include "ue_wrap/core/script_gate.h" #include "ue_wrap/devices/serverbox.h" +#include "ue_wrap/world/economy.h" // AddPoints and the saveSlot: the repair reward, paid on the host #include "ue_wrap/engine/world_identity.h" // Generation, the baseline's anchor #include "ue_wrap/world/world_singleton.h" // Gamemode, its other anchor @@ -198,6 +199,75 @@ sg::Verdict OnBreakPre(const sg::Call& call) { return sg::Verdict::Cancel; } +// The minigame's reward, read from ui_serverMinigame's end(correct): a box broken by damage pays nothing; +// any other pays SelectInt(50, 15, isLol) and runs fix; then a solve time under saveSlot.servertimeBest +// pays SelectInt(100, 30, isLol) more, and the best becomes the time (the first solve only sets it). +constexpr int32_t kRewardPlain = 15, kRewardLol = 50, kRecordPlain = 30, kRecordLol = 100; + +// CLIENT: the widget's own fields the reward reads, at the moment its fix is sent. +void ReadWidgetReward(void* widget, coop::net::ServerRepairPayload& p) { + static void* sCls = nullptr; + static int32_t sOffLol = -1, sOffTime = -1; + static uint8_t sMaskLol = 0; + void* cls = R::ClassOf(widget); + if (cls != sCls) { + sCls = cls; + sOffLol = -1; sOffTime = -1; sMaskLol = 0; + if (!R::FindBoolProperty(cls, L"isLol", sOffLol, sMaskLol)) sOffLol = -1; + sOffTime = R::FindPropertyOffset(cls, L"time"); + } + if (sOffLol < 0 || sOffTime < 0) { + UE_LOGW("serverbox_sync: the repair widget's isLol/time did not resolve -- the host fixes without the reward"); + return; + } + const auto* base = static_cast(widget); + float t = 0.f; + std::memcpy(&t, base + sOffTime, sizeof(t)); + p.flags = static_cast(coop::net::kRepairSettles | + ((base[sOffLol] & sMaskLol) ? coop::net::kRepairLol : 0)); + const float ds = std::isfinite(t) && t > 0.f ? t * 10.f + 0.5f : 0.f; + p.timeDs = static_cast(ds >= 65535.f ? 65535.f : ds); +} + +// CLIENT: the widget's points are the host's to pay with the fix, so its own credit is refused here: the +// shared balance would take it, then drop it when the host's row lands, and the host's later payment showed +// as the difference (a +15 reward, then +60 from the task, read +45). +uint64_t g_rewardsRefused = 0; +std::chrono::steady_clock::time_point g_nextRewardSay{}; +sg::Verdict OnRewardPre(const sg::Call& call) { + if (!ClientSession() || !SB::IsRepairWidget(call.callerObject)) return sg::Verdict::Run; + ++g_rewardsRefused; + if (SayNow(g_nextRewardSay)) + UE_LOGI("serverbox_sync: this client's repair reward refused (%llu) -- the host pays it with the fix", + static_cast(g_rewardsRefused)); + return sg::Verdict::Cancel; +} + +// HOST: the reward of a client's repair the host just ran, as the widget computes it, from the host's own +// box and saveSlot. Returns the base reward; `record` gets the record bonus. +int32_t PayRepairReward(const coop::net::ServerRepairPayload& p, bool damaged, int32_t& record) { + record = 0; + if (!(p.flags & coop::net::kRepairSettles) || damaged) return 0; + const bool lol = (p.flags & coop::net::kRepairLol) != 0; + const int32_t reward = lol ? kRewardLol : kRewardPlain; + ue_wrap::economy::AddPoints(reward); + if (p.timeDs == 0) return reward; + void* save = ue_wrap::economy::SaveSlotPtr(); + static int32_t sOffBest = -1; + if (save && sOffBest < 0) sOffBest = R::FindPropertyOffset(R::ClassOf(save), L"servertimeBest"); + if (!save || sOffBest < 0) return reward; + float best = 0.f; + std::memcpy(&best, static_cast(save) + sOffBest, sizeof(best)); + const float t = static_cast(p.timeDs) / 10.f; + if (t < best) { + record = lol ? kRecordLol : kRecordPlain; + ue_wrap::economy::AddPoints(record); + } + const float next = best == 0.f ? t : std::fmin(best, t); + std::memcpy(static_cast(save) + sOffBest, &next, sizeof(next)); + return reward; +} + // CLIENT: its player's repair, the gamemode's repair widget calling fix, goes to the host; any other fix is refused. sg::Verdict OnFixPre(const sg::Call& call) { auto* s = ClientSession(); @@ -216,6 +286,7 @@ sg::Verdict OnFixPre(const sg::Call& call) { } coop::net::ServerRepairPayload p{}; p.box = static_cast(box); + ReadWidgetReward(call.callerObject, p); if (s->SendReliableToSlot(0, coop::net::ReliableKind::ServerRepair, &p, sizeof(p))) ++g_counts.repairsSent; UE_LOGI("serverbox_sync: this player's repair of box %d sent to the host (%llu sent)", box, static_cast(g_counts.repairsSent)); @@ -228,11 +299,13 @@ struct Watch { sg::PreFn pre; bool registered = false; bool settled = false; + const wchar_t* cls = kBoxClass; }; Watch g_watches[] = { {kBreakVerb, kTagBreak, &OnBreakPre}, {kTypeVerb, kTagType, &OnBreakPre}, {kFixVerb, kTagFix, &OnFixPre}, + {L"addPoints", 0x53425034 /* 'SBP4' */, &OnRewardPre, false, false, L"lib_C"}, }; bool g_watchesSettled = false; @@ -243,19 +316,19 @@ void DriveWatches() { for (Watch& w : g_watches) { if (w.settled) continue; if (!w.registered) { - w.registered = sg::WatchClassName(kBoxClass, w.fn, w.tag, w.pre, nullptr); + w.registered = sg::WatchClassName(w.cls, w.fn, w.tag, w.pre, nullptr); if (!w.registered) { w.settled = true; - UE_LOGE("serverbox_sync: the gate took no watch on %ls::%ls -- a client authors that verb", kBoxClass, + UE_LOGE("serverbox_sync: the gate took no watch on %ls::%ls -- a client authors that verb", w.cls, w.fn); continue; } } - if (sg::ClassNameWatchSettled(kBoxClass, w.fn, w.tag)) { + if (sg::ClassNameWatchSettled(w.cls, w.fn, w.tag)) { w.settled = true; - if (sg::ClassNameWatchLive(kBoxClass, w.fn, w.tag)) UE_LOGI("serverbox_sync: the watch on %ls::%ls is live", - kBoxClass, w.fn); - else UE_LOGE("serverbox_sync: the watch on %ls::%ls settled dead -- a client authors that verb", kBoxClass, + if (sg::ClassNameWatchLive(w.cls, w.fn, w.tag)) UE_LOGI("serverbox_sync: the watch on %ls::%ls is live", + w.cls, w.fn); + else UE_LOGE("serverbox_sync: the watch on %ls::%ls settled dead -- a client authors that verb", w.cls, w.fn); continue; } @@ -335,6 +408,7 @@ void OnRepair(const coop::net::ServerRepairPayload& payload, int senderPeerSlot) ReadServers(servers); void* box = payload.box < servers.size() ? servers[payload.box] : nullptr; const char* why = nullptr; + SB::RepairState repair{}; // the damage flag the reward reads, taken before the fix clears it if (!box || !R::IsLive(box)) why = "no such box"; else if (!SB::ReadIsBroken(box)) why = "the box is not broken here"; else { @@ -343,7 +417,7 @@ void OnRepair(const coop::net::ServerRepairPayload& payload, int senderPeerSlot) else if (const auto outcome = token.Authorize(box).outcome; outcome != coop::element::IntentOutcome::Ok) why = outcome == coop::element::IntentOutcome::NoTarget ? "the box's place did not read" : "the box is out of its reach"; - else if (!SB::CallFix(box)) why = "the box's fix did not run"; + else if (!SB::ReadRepairState(box, repair) || !SB::CallFix(box)) why = "the box's fix did not run"; } if (why) { ++g_counts.repairsRefused; @@ -354,7 +428,10 @@ void OnRepair(const coop::net::ServerRepairPayload& payload, int senderPeerSlot) return; } ++g_counts.repairsRun; - UE_LOGI("serverbox_sync: HOST ran slot %u's repair of box %u", slot, payload.box); + int32_t record = 0; + const int32_t reward = PayRepairReward(payload, repair.damaged, record); + UE_LOGI("serverbox_sync: HOST ran slot %u's repair of box %u (reward %d, record %d%s)", slot, payload.box, + reward, record, (payload.flags & coop::net::kRepairSettles) ? "" : ", the sender sent no reward fields"); BroadcastIfChanged(s, "a client's repair"); } From 1bb1d1acea0047be49dfef9bbb950804ef069b5f Mon Sep 17 00:00:00 2001 From: pelmentor Date: Fri, 9 Oct 2026 23:53:44 +0600 Subject: [PATCH 023/306] [coop] the repair reward's inputs are bounded and its record is shared The adopted lane read the widget's lol flag and time and the saveSlot's best by reflection inside the lane, and wrote the best raw; those reads and the write now live with the server box in ue_wrap. The client's time is its own report, so a time under a second settles no record and leaves the host's best alone: one impossible time would have held the record out of every honest solve's reach for the life of the save. The host's best rides the server row, so each peer's widget judges a record against the best the host pays from, and a credit that does not land is not reported as paid. Build 219: the server row is 100 bytes. Co-Authored-By: Claude Opus 5.5 --- docs/coop-dispatch-visibility.md | 1 + docs/devices.md | 5 +- src/votv-coop/include/coop/net/protocol.h | 5 +- .../include/ue_wrap/devices/serverbox.h | 9 ++++ .../src/coop/interactables/serverbox_sync.cpp | 54 ++++++++----------- .../src/ue_wrap/devices/serverbox.cpp | 52 ++++++++++++++++++ 6 files changed, 92 insertions(+), 34 deletions(-) diff --git a/docs/coop-dispatch-visibility.md b/docs/coop-dispatch-visibility.md index 4e5deebcd..8a26b6ddf 100644 --- a/docs/coop-dispatch-visibility.md +++ b/docs/coop-dispatch-visibility.md @@ -126,6 +126,7 @@ Never spawn from an observer directly; post it. `[V]` | `signalDriveEraser_C::actionOptionIndex`, action 4, the eraser's delete (a 3 s `Delay`, then the wipe of the drive in that peer's slot) | Blueprint call | no, to the detour | the script-body gate watches it BY NAME, PRE: a client's press is refused and sent with the drive it saw seated; the host presses its own eraser once its slot holds that drive, and its POST sends the press's show to every client (`eraser_press_intent`) `[V]` for a reflected press (the drive drill); `[RD]` for the player's own button, which reaches the same function | | `serverBox_C::breakServer` and `serverBox_C::break_type`, a box's break (eight world events and a load reach the first: lightning, `addDamage`, `ticker_serverBreaker`, the server-breaker prop, three triggers, the dish's `loadData`; the desk's virus event the second) | Blueprint calls | no, to the detour | the script-body gate watches both BY NAME, PRE: a client refuses them through its whole connected session; the host's run, and its row carries the state (`serverbox_sync`) `[V]` (the server drill: a client's own breakServer refused) | | `serverBox_C::fix`, a box's repair (the gamemode's repair widget's `end(true)`, the Kerfur, `p_kerfus`) | Blueprint calls | no, to the detour | the script-body gate watches it BY NAME, PRE: on a client the repair widget's call is refused and sent to the host as a repair intent, which the host runs within reach, and every other caller's is refused (`serverbox_sync`) `[V]` (the server drill: the repair widget's fix refused on the client and run on the host) | +| `lib_C::addPoints` from the repair widget (its `end(correct)` reward and record) | Blueprint call on `Default__lib_C` | no, to the detour | the script-body gate watches it BY NAME, PRE, and refuses it on a client when the caller is the gamemode's repair widget: the host pays that reward with the repair (`serverbox_sync`) `[RD]` | | `ExecuteUbergraph_signalDriveEraser` at entry 15, the eraser press's resume after its `Delay` | the latent action manager's call through ProcessEvent | no, to the detour | the script-body gate watches it BY NAME, POST, on the host: a cleared `processing` is a wipe, a set one a deny, and each client's eraser shows it (`eraser_press_intent`) `[V]` (the drive drill: the host's done shown on the client) | | `analogDScreenTest_C::formDownload`, the download's arm (`dishesStop`'s call when the dishes stop; the desk's load) | a local call from the desk's ubergraph | no, to the detour | the script-body gate watches it BY NAME: the host's POST sends the arm with its decoded and polarity; a client's PRE writes the host's values into its parameters while a replay runs, and refuses the verb outside one in the world it announced (`download_arm_sync`) `[V]` (the download drill: one formDownload body an arm, the host's polarity) | | `analogDScreenTest_C::initDownloadSignal`, which rolls the download's polarity when handed -1 (inside `formDownload` and the desk's reset) | a call through a context switch (`gamemode->analogPanels`) and a local call | no, to the detour | inside a client's reset replay the gate's PRE writes the host's polarity (`download_arm_sync`) `[V]` (the download drill: the reset's polarity the host's) | diff --git a/docs/devices.md b/docs/devices.md index d34c8fcbf..677d9e658 100644 --- a/docs/devices.md +++ b/docs/devices.md @@ -423,7 +423,10 @@ alone. The widget's points are the shared balance's, so a client's own credit fr refused and the intent carries what the reward reads (the lol mode and the solve time): the host pays the repair (15, or 50 in lol mode, nothing for a box broken by damage) and a record under its own best time (30, or 100) in the same step as the fix, and a repair it refuses pays nothing. The -widget's stats stay the player's. The host polls each box's broken and +lol mode and the solve time are the client's report, which the host cannot check: a time under a +second settles no record, and the host's best rides the server row to every peer, so each widget +judges a record against the best the host pays from. The repair count stays the player's; the +points total is the host's, its payment counting it. The host polls each box's broken and damaged flags, the repair type its break rolled, and the three totals the gamemode keeps for the farm, broadcasts a change at once after a repair and within a second otherwise, and a client writes the flags and the type and calls the box's own re-skin, which is notify-free and so repaints diff --git a/src/votv-coop/include/coop/net/protocol.h b/src/votv-coop/include/coop/net/protocol.h index eb1ab54e8..1fea3247f 100644 --- a/src/votv-coop/include/coop/net/protocol.h +++ b/src/votv-coop/include/coop/net/protocol.h @@ -30,7 +30,7 @@ inline constexpr uint32_t kMagic = 0x564D5450u; // This file is past the 1500-line hard cap and stays there: it is the single-feature exception the // rule names. One wire format, whose enum, payload structs and static_asserts are read together; // splitting it would put a kind's number in one file and its bytes in another. -inline constexpr uint16_t kProtocolVersion = 218; +inline constexpr uint16_t kProtocolVersion = 219; // Default LAN port (overridable via multivoid.ini "net.port="). inline constexpr uint16_t kDefaultPort = 47621; @@ -1809,8 +1809,9 @@ struct ServerStatePayload { uint64_t isBrokenMask; // 8 -- bit i = servers[i].IsBroken (up to 64 servers) uint64_t damagedMask; // 8 -- bit i = servers[i].damaged (a break from damage pays no repair points) uint8_t minigame[64]; // 64 -- servers[i].minigame, the repair type (0..255; the game rolls a small index) + float timeBest; // 4 -- saveSlot.servertimeBest: the repair record the host pays against (0 = none) }; -static_assert(sizeof(ServerStatePayload) == 96, "ServerStatePayload must be 96 bytes"); +static_assert(sizeof(ServerStatePayload) == 100, "ServerStatePayload must be 100 bytes"); // A player's finished repair (ServerRepair), client to host: the box by its servers[] index, and what the // minigame's own reward reads -- its lol mode and the solve time -- so the host pays it in the same step as diff --git a/src/votv-coop/include/ue_wrap/devices/serverbox.h b/src/votv-coop/include/ue_wrap/devices/serverbox.h index e0b089720..28cdfaa46 100644 --- a/src/votv-coop/include/ue_wrap/devices/serverbox.h +++ b/src/votv-coop/include/ue_wrap/devices/serverbox.h @@ -104,6 +104,15 @@ bool IsRepairWidget(void* obj); // then its end(correct), whose success pays the player and calls the box's fix. bool CallRepairEnd(void* box, bool correct); +// The repair widget's reward inputs, as its end(correct) reads them: its lol mode (the larger rewards) and the solve +// time its Tick accumulates. False when either member did not resolve. Game thread. +bool ReadRepairReward(void* widget, bool& lol, float& time); + +// The repair minigame's record, saveSlot.servertimeBest (0 until a first solve): read, and written the way +// end(correct) writes it. False when the saveSlot or the member did not resolve. Game thread. +bool ReadRepairBest(float& out); +bool WriteRepairBest(float value); + // ---- server upgrades ------------------------------------------------------------------------------ // A box takes up to three physical upgrades. Its install is playerUsedOn with a held prop_serverUpg_C: diff --git a/src/votv-coop/src/coop/interactables/serverbox_sync.cpp b/src/votv-coop/src/coop/interactables/serverbox_sync.cpp index edd197ea9..a02677252 100644 --- a/src/votv-coop/src/coop/interactables/serverbox_sync.cpp +++ b/src/votv-coop/src/coop/interactables/serverbox_sync.cpp @@ -101,6 +101,7 @@ bool ReadState(coop::net::ServerStatePayload& p) { p.effCalc = agg.efficiencyCalc; p.effDownl = agg.efficiencyDownload; p.serverCount = static_cast(servers.size()); + if (!SB::ReadRepairBest(p.timeBest)) p.timeBest = 0.f; return true; } @@ -118,7 +119,7 @@ bool StateChanged(const coop::net::ServerStatePayload& p) { p.brokenServers != g_last.brokenServers || p.serverCount != g_last.serverCount || std::memcmp(p.minigame, g_last.minigame, sizeof(p.minigame)) != 0 || std::fabs(p.effCalc - g_last.effCalc) > 0.005f || - std::fabs(p.effDownl - g_last.effDownl) > 0.005f; + std::fabs(p.effDownl - g_last.effDownl) > 0.005f || p.timeBest != g_last.timeBest; } // HOST: the row to every client, on a change of the baseline. @@ -148,6 +149,10 @@ void ApplyState(const coop::net::ServerStatePayload& p) { agg.efficiencyCalc = p.effCalc; agg.efficiencyDownload = p.effDownl; if (!SB::WriteAggregates(agg)) return; + // The record the host pays against, so this peer's repair widget judges a record (its sound, its own best write) + // against the same best the host's payment does. The widget still writes its own solve here first; this row, + // broadcast after the host's repair, puts the host's answer over it. + if (std::isfinite(p.timeBest) && p.timeBest >= 0.f) SB::WriteRepairBest(p.timeBest); std::vector servers; ReadServers(servers); const bool repair = SB::EnsureRepairResolved(); @@ -206,25 +211,13 @@ constexpr int32_t kRewardPlain = 15, kRewardLol = 50, kRecordPlain = 30, kRecord // CLIENT: the widget's own fields the reward reads, at the moment its fix is sent. void ReadWidgetReward(void* widget, coop::net::ServerRepairPayload& p) { - static void* sCls = nullptr; - static int32_t sOffLol = -1, sOffTime = -1; - static uint8_t sMaskLol = 0; - void* cls = R::ClassOf(widget); - if (cls != sCls) { - sCls = cls; - sOffLol = -1; sOffTime = -1; sMaskLol = 0; - if (!R::FindBoolProperty(cls, L"isLol", sOffLol, sMaskLol)) sOffLol = -1; - sOffTime = R::FindPropertyOffset(cls, L"time"); - } - if (sOffLol < 0 || sOffTime < 0) { + bool lol = false; + float t = 0.f; + if (!SB::ReadRepairReward(widget, lol, t)) { UE_LOGW("serverbox_sync: the repair widget's isLol/time did not resolve -- the host fixes without the reward"); return; } - const auto* base = static_cast(widget); - float t = 0.f; - std::memcpy(&t, base + sOffTime, sizeof(t)); - p.flags = static_cast(coop::net::kRepairSettles | - ((base[sOffLol] & sMaskLol) ? coop::net::kRepairLol : 0)); + p.flags = static_cast(coop::net::kRepairSettles | (lol ? coop::net::kRepairLol : 0)); const float ds = std::isfinite(t) && t > 0.f ? t * 10.f + 0.5f : 0.f; p.timeDs = static_cast(ds >= 65535.f ? 65535.f : ds); } @@ -243,28 +236,27 @@ sg::Verdict OnRewardPre(const sg::Call& call) { return sg::Verdict::Cancel; } +// The lol mode and the solve time are the client's report: the host cannot see the minigame, so it bounds what it +// takes (bounds apply to clients). A time under this settles no record and leaves the host's best alone -- one +// impossible time written there would sit out of every honest solve's reach for the life of the save. One second +// is a bound chosen to sit under any solve by hand, not a measured minimum. +constexpr uint16_t kMinSolveDs = 10; + // HOST: the reward of a client's repair the host just ran, as the widget computes it, from the host's own -// box and saveSlot. Returns the base reward; `record` gets the record bonus. +// box and saveSlot. Returns the base reward paid; `record` gets the record bonus paid. int32_t PayRepairReward(const coop::net::ServerRepairPayload& p, bool damaged, int32_t& record) { record = 0; if (!(p.flags & coop::net::kRepairSettles) || damaged) return 0; const bool lol = (p.flags & coop::net::kRepairLol) != 0; const int32_t reward = lol ? kRewardLol : kRewardPlain; - ue_wrap::economy::AddPoints(reward); - if (p.timeDs == 0) return reward; - void* save = ue_wrap::economy::SaveSlotPtr(); - static int32_t sOffBest = -1; - if (save && sOffBest < 0) sOffBest = R::FindPropertyOffset(R::ClassOf(save), L"servertimeBest"); - if (!save || sOffBest < 0) return reward; + if (!ue_wrap::economy::AddPoints(reward)) return 0; + if (p.timeDs < kMinSolveDs) return reward; // no time, or none a hand makes: the reward, no record float best = 0.f; - std::memcpy(&best, static_cast(save) + sOffBest, sizeof(best)); + if (!SB::ReadRepairBest(best)) return reward; const float t = static_cast(p.timeDs) / 10.f; - if (t < best) { - record = lol ? kRecordLol : kRecordPlain; - ue_wrap::economy::AddPoints(record); - } - const float next = best == 0.f ? t : std::fmin(best, t); - std::memcpy(static_cast(save) + sOffBest, &next, sizeof(next)); + const int32_t bonus = lol ? kRecordLol : kRecordPlain; + if (t < best && ue_wrap::economy::AddPoints(bonus)) record = bonus; + SB::WriteRepairBest(best == 0.f ? t : std::fmin(best, t)); return reward; } diff --git a/src/votv-coop/src/ue_wrap/devices/serverbox.cpp b/src/votv-coop/src/ue_wrap/devices/serverbox.cpp index b585fff26..9e1445d6e 100644 --- a/src/votv-coop/src/ue_wrap/devices/serverbox.cpp +++ b/src/votv-coop/src/ue_wrap/devices/serverbox.cpp @@ -8,6 +8,7 @@ #include "ue_wrap/core/log.h" #include "ue_wrap/core/object_index.h" #include "ue_wrap/core/reflection.h" +#include "ue_wrap/world/economy.h" // SaveSlotPtr: the repair record's saveSlot #include "ue_wrap/world/world_singleton.h" #include "ue_wrap/core/sdk_profile_names.h" #include "ue_wrap/engine/engine.h" @@ -16,6 +17,7 @@ #include "ue_wrap/engine/world_identity.h" #include +#include namespace ue_wrap::serverbox { namespace { @@ -313,6 +315,56 @@ bool CallRepairEnd(void* box, bool correct) { return f.valid() && f.Set(L"correct", correct) && Call(w, f); } +namespace { +void* g_rewardCls = nullptr; // the widget class the two reward offsets were read from +int32_t g_offIsLol = -1; // ui_serverMinigame_C.isLol, its byte and bit +uint8_t g_maskIsLol = 0; +int32_t g_offTime = -1; // ui_serverMinigame_C.time (float, the solve time) +void* g_bestCls = nullptr; // the saveSlot class servertimeBest was read from +int32_t g_offBest = -1; // saveSlot.servertimeBest (float) + +float* RepairBestField() { + void* save = economy::SaveSlotPtr(); + if (!save) return nullptr; + void* cls = R::ClassOf(save); + if (cls != g_bestCls) { + g_bestCls = cls; + g_offBest = R::FindPropertyOffset(cls, L"servertimeBest"); + } + return g_offBest < 0 ? nullptr : reinterpret_cast(static_cast(save) + g_offBest); +} +} // namespace + +bool ReadRepairReward(void* widget, bool& lol, float& time) { + if (!widget) return false; + void* cls = R::ClassOf(widget); + if (cls != g_rewardCls) { + g_rewardCls = cls; + g_maskIsLol = 0; + if (!R::FindBoolProperty(cls, L"isLol", g_offIsLol, g_maskIsLol)) g_offIsLol = -1; + g_offTime = R::FindPropertyOffset(cls, L"time"); + } + if (g_offIsLol < 0 || g_offTime < 0) return false; + const auto* base = static_cast(widget); + lol = (base[g_offIsLol] & g_maskIsLol) != 0; + std::memcpy(&time, base + g_offTime, sizeof(time)); + return true; +} + +bool ReadRepairBest(float& out) { + const float* f = RepairBestField(); + if (!f) return false; + out = *f; + return true; +} + +bool WriteRepairBest(float value) { + float* f = RepairBestField(); + if (!f) return false; + *f = value; + return true; +} + namespace { int32_t g_offUpgrades = -1; // serverBox_C.upgrades (int) bool g_upgradesMissing = false; // the class loaded without the member: never retried From 0ad9b7a7b8b461f38f9d1cc797f1a9e4e651ad98 Mon Sep 17 00:00:00 2001 From: Shinobu2 Date: Mon, 5 Oct 2026 00:34:01 -0700 Subject: [PATCH 024/306] [coop] a falling prop the host births streams its pose until it rests A prop the host spawned simulating fell under each peer's own physics, and the copies came to rest apart: a spawn-menu barrel lay rolled on the host and stood upright on the client. Such births now coast on the driven-prop stream from birth until they rest, and its end edge parks every copy at the host's final pose. A birth the init seam already tracked, which is a spawn-menu barrel's route, queues too. Frozen, static and sleeping births are left alone, and at most 32 unclaimed props coast at once, a cap hit said once. The net of the two commits below; their container half rides the container lane. Co-Authored-By: Claude Opus 5.5 (cherry picked from commit 658e72250dd29bb69145dec9c4e274fe09e095c1) (cherry picked from commit d0421a78e177c72373e6989fd70a35f288dec436) --- docs/props.md | 8 +++- .../include/coop/props/prop_drive_host.h | 5 +++ .../src/coop/props/host_spawn_watcher.cpp | 43 ++++++++++++++++--- .../src/coop/props/prop_drive_host.cpp | 7 +++ 4 files changed, 56 insertions(+), 7 deletions(-) diff --git a/docs/props.md b/docs/props.md index f12612b0d..06a213c90 100644 --- a/docs/props.md +++ b/docs/props.md @@ -158,8 +158,12 @@ the set's second feeder: every stroke is the host's ([piles.md](piles.md)), and pushes coasts under the stream until it rests, while a prop a verb already holds stays that verb's (`coop/items/broom_push`). The trash a stroke knocks out of a dispenser pile coasts too, from its spawn: the host catches the finish of each spawn the pile's own `broomed` body makes, and the -streams open after the tick's spawns are named, so each has its id by then. A prop that a pushed -prop knocks has no verb of its own and is not streamed. A receiver parks the prop on the first pose +streams open after the tick's spawns are named, so each has its id by then. The third feeder is +the host's own births: a prop it spawns simulating -- a spawn-menu barrel in mid-air, a forage +drop, a prop built from a client's intent -- coasts from its birth until it rests, since every +peer would otherwise simulate its own fall and the copies came to rest apart; at most 32 props +are coasting at once, and a burst past that falls locally (`coop/props/host_spawn_watcher`). A +prop that a pushed prop knocks has no verb of its own and is not streamed. A receiver parks the prop on the first pose and follows the stream the way a carried trash clump is followed -- the fixed-delay interpolation, frozen at the last pose across a gap -- where a held prop snaps to each pose; a reliable end edge carries the final pose, the host's physics flags and the velocity once the prop has rested or a hand diff --git a/src/votv-coop/include/coop/props/prop_drive_host.h b/src/votv-coop/include/coop/props/prop_drive_host.h index 9823e8b00..d270162d7 100644 --- a/src/votv-coop/include/coop/props/prop_drive_host.h +++ b/src/votv-coop/include/coop/props/prop_drive_host.h @@ -14,6 +14,8 @@ #pragma once +#include + namespace coop::net { class Session; } namespace coop::prop_drive_host { @@ -25,6 +27,9 @@ namespace coop::prop_drive_host { // class the wire never expresses. `reason` names the verb for the log. Game thread. void Claim(void* actor, const char* reason); +// How many props coast with no verb's claim. Game thread. +size_t CoastingCount(); + // The verb let go of `actor`: the prop coasts under the stream until it rests, then the end edge // fires. A no-op for an unclaimed actor. Game thread. void Release(void* actor); diff --git a/src/votv-coop/src/coop/props/host_spawn_watcher.cpp b/src/votv-coop/src/coop/props/host_spawn_watcher.cpp index 95642b164..54fe98cde 100644 --- a/src/votv-coop/src/coop/props/host_spawn_watcher.cpp +++ b/src/votv-coop/src/coop/props/host_spawn_watcher.cpp @@ -11,6 +11,8 @@ #include "coop/props/prop_element_tracker.h" #include "coop/props/prop_lifecycle.h" // ExpressSpawnedProp (reuse the keyed broadcast) #include "coop/props/prop_save_data.h" // PublishHostBirth (the record behind the express) +#include "coop/props/prop_drive_host.h" // Coast (a falling birth's pose until it rests) +#include "coop/props/prop_wire_parity.h" // PhysFlagsOf (frozen, static and sleep) #include "coop/props/remote_prop_spawn.h" #include "coop/props/join_membership_sweep.h" // the join claim and sweep #include "ue_wrap/core/game_thread.h" @@ -231,7 +233,8 @@ void OnFinishSpawnFunc(void* /*context*/, void* /*srcObj*/, void* result) { // expressing a mirror as a fresh world prop is the dupe. if (coop::prop_echo_suppress::IsMirrorSpawn(actor)) return; if (!ue_wrap::prop::IsDescendantOfProp(actor)) return; // KEYED Aprop_C lineage only - if (PT::GetPropElementIdForActor(actor) != coop::element::kInvalidId) return; // already tracked + // An actor the init seam already tracked is queued too: its row is out, but its fall still + // needs the drain's post-birth work (a spawn-menu barrel takes that route). if (g_pendingFinished.size() >= kMaxPendingFinished) { UE_LOGW("host_spawn_watcher: pending-finished cap %zu hit -- dropping %p (safety census catches it)", kMaxPendingFinished, actor); @@ -352,6 +355,33 @@ void TickWatchedProps(coop::net::Session* s) { } } +// A birth this host simulates -- a spawn-menu prop in mid-air, a forage drop, a prop built from a +// client's intent -- falls under each peer's own physics, and two simulations of one fall part +// ways: a barrel that rolled on the host stood upright on the client. The fall goes out on the +// driven-prop stream until the body rests, and the stream's end parks every copy at the host's +// final pose. Frozen, static and sleeping births do not move and are left alone, and past the cap +// a burst of births -- a spawn-menu pile, an event's debris -- falls locally as it always did, so +// the stream's per-tick reads stay bounded. +constexpr size_t kMaxCoastingBirths = 32; +namespace { +void CoastIfFalling(void* actor) { + namespace pf = coop::net::propspawn_flags; + if (coop::prop_drive_host::CoastingCount() >= kMaxCoastingBirths) { + static bool sSaid = false; + if (!sSaid) { + sSaid = true; + UE_LOGW("host_spawn_watcher: %zu births already coasting -- a further birth falls locally " + "(said once)", kMaxCoastingBirths); + } + return; + } + if (PT::GetPropElementIdForActor(actor) == coop::element::kInvalidId) return; + const uint8_t flags = coop::prop_wire_parity::PhysFlagsOf(actor); + if (flags & (pf::kFrozen | pf::kStatic | pf::kSleep)) return; + coop::prop_drive_host::Coast(actor, "host birth"); +} +} // namespace + void DrainPendingSpawns(coop::net::Session* s) { UE_ASSERT_GAME_THREAD("host_spawn_watcher::DrainPendingSpawns"); if (g_pendingFinished.empty()) return; @@ -361,16 +391,18 @@ void DrainPendingSpawns(coop::net::Session* s) { } for (const PendingFinishedSpawn& e : g_pendingFinished) { if (!e.actor || !R::IsLiveByIndex(e.actor, e.idx)) continue; // died before adopt - // Tracked or mirror-bound since the enqueue (the unified Registry reverse map covers both - // local elements and wire mirrors): someone owns it. - if (coop::element::Registry::Get().EidForActor(e.actor) != coop::element::kInvalidId) - continue; // Marked as a mirror after our enqueue check: not ours. if (coop::prop_echo_suppress::IsMirrorSpawn(e.actor)) continue; // The local hotbar hand actor, the hold update's view spawn: display-only while held, and // the hand edge in the hand-item module expresses it if and when it is released into the // world. Never adopted here. if (e.actor == coop::hand_item::LocalHandActor()) continue; + // Tracked since the enqueue (the init seam expressed it first): someone owns its row, and + // only its fall is left to give a channel. + if (coop::element::Registry::Get().EidForActor(e.actor) != coop::element::kInvalidId) { + CoastIfFalling(e.actor); + continue; + } // The one-shot canonical express (the filters, the key gate, the payload, the self-claim). // It latches the processed-init mark on first touch, so this entry is spent regardless of // outcome; a still-unkeyed straggler falls to the periodic safety census. @@ -387,6 +419,7 @@ void DrainPendingSpawns(coop::net::Session* s) { // it every client got the class default -- bug 19's run 1: nine of nine discs the // host ejected read EMPTIED on the client and INTACT on the host. coop::prop_save_data::PublishHostBirth(s, e.actor); + CoastIfFalling(e.actor); } } g_pendingFinished.clear(); diff --git a/src/votv-coop/src/coop/props/prop_drive_host.cpp b/src/votv-coop/src/coop/props/prop_drive_host.cpp index 1198c96e7..543471e55 100644 --- a/src/votv-coop/src/coop/props/prop_drive_host.cpp +++ b/src/votv-coop/src/coop/props/prop_drive_host.cpp @@ -193,6 +193,13 @@ void Coast(void* actor, const char* reason) { Open(actor, /*claimed=*/false, reason ? reason : ""); } +size_t CoastingCount() { + size_t n = 0; + for (const Driven& d : g_driven) + if (!d.claimed && !d.dead) ++n; + return n; +} + void Release(void* actor) { UE_ASSERT_GAME_THREAD("prop_drive_host::Release"); Driven* d = Find(actor); From b2d5ecd2cddeb617575099b7a5b4c0c6ef1cf46f Mon Sep 17 00:00:00 2001 From: pelmentor Date: Sat, 10 Oct 2026 00:02:39 +0600 Subject: [PATCH 025/306] [coop] only a birth that can fall is queued and coasted The adopted lane queued every birth the init seam had already tracked so that a spawn-menu barrel could coast, which put every tracked host birth -- a delivery, a world change's props -- into a 128-row queue and its cap warning, and the coast opened a row for any prop not frozen, static or asleep, a body born resting included. Both now ask one thing: is the body simulating and awake. A tracked birth that is not stays out of the queue, and the coast opens no row for it. Co-Authored-By: Claude Opus 5.5 --- .../include/coop/props/host_spawn_watcher.h | 4 +++- .../src/coop/props/host_spawn_watcher.cpp | 15 ++++++++++----- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/src/votv-coop/include/coop/props/host_spawn_watcher.h b/src/votv-coop/include/coop/props/host_spawn_watcher.h index a4e25550a..efb08004f 100644 --- a/src/votv-coop/include/coop/props/host_spawn_watcher.h +++ b/src/votv-coop/include/coop/props/host_spawn_watcher.h @@ -43,7 +43,9 @@ void Install(coop::net::Session* session); // Drain the FinishSpawningActor pending queue: express (prop_lifecycle::ExpressSpawnedProp) // every finished keyed Aprop_C spawn that is live, still untracked, and NOT the local hotbar // hand actor (the hand edge in coop/player/hand_item owns that actor's eventual world release), -// and publish the save record of each one it adopted (prop_save_data::PublishHostBirth). +// and publish the save record of each one it adopted (prop_save_data::PublishHostBirth). A birth +// that simulates and is awake, adopted here or tracked by the init seam before its finish, coasts +// on the driven-prop stream until it rests (prop_drive_host::Coast), at most 32 at once. // ONE express attempt per entry -- the queue is cleared unconditionally, so a still-unkeyed // actor falls to the periodic safety census rather than being retried here. Host-side express; // game thread (net-pump tick). diff --git a/src/votv-coop/src/coop/props/host_spawn_watcher.cpp b/src/votv-coop/src/coop/props/host_spawn_watcher.cpp index 54fe98cde..d559ee751 100644 --- a/src/votv-coop/src/coop/props/host_spawn_watcher.cpp +++ b/src/votv-coop/src/coop/props/host_spawn_watcher.cpp @@ -233,8 +233,12 @@ void OnFinishSpawnFunc(void* /*context*/, void* /*srcObj*/, void* result) { // expressing a mirror as a fresh world prop is the dupe. if (coop::prop_echo_suppress::IsMirrorSpawn(actor)) return; if (!ue_wrap::prop::IsDescendantOfProp(actor)) return; // KEYED Aprop_C lineage only - // An actor the init seam already tracked is queued too: its row is out, but its fall still - // needs the drain's post-birth work (a spawn-menu barrel takes that route). + // An actor the init seam already tracked has its row out; only a fall is left to give it, so it + // is queued when it can fall (a spawn-menu barrel takes that route) and dropped here otherwise, + // which keeps every other tracked birth out of the queue and its cap. + if (PT::GetPropElementIdForActor(actor) != coop::element::kInvalidId && + !(coop::prop_wire_parity::PhysFlagsOf(actor) & coop::net::propspawn_flags::kSimulatePhysics)) + return; if (g_pendingFinished.size() >= kMaxPendingFinished) { UE_LOGW("host_spawn_watcher: pending-finished cap %zu hit -- dropping %p (safety census catches it)", kMaxPendingFinished, actor); @@ -359,7 +363,7 @@ void TickWatchedProps(coop::net::Session* s) { // client's intent -- falls under each peer's own physics, and two simulations of one fall part // ways: a barrel that rolled on the host stood upright on the client. The fall goes out on the // driven-prop stream until the body rests, and the stream's end parks every copy at the host's -// final pose. Frozen, static and sleeping births do not move and are left alone, and past the cap +// final pose. A birth that is not simulating and awake does not move and is left alone, and past the cap // a burst of births -- a spawn-menu pile, an event's debris -- falls locally as it always did, so // the stream's per-tick reads stay bounded. constexpr size_t kMaxCoastingBirths = 32; @@ -376,8 +380,9 @@ void CoastIfFalling(void* actor) { return; } if (PT::GetPropElementIdForActor(actor) == coop::element::kInvalidId) return; - const uint8_t flags = coop::prop_wire_parity::PhysFlagsOf(actor); - if (flags & (pf::kFrozen | pf::kStatic | pf::kSleep)) return; + // Simulating and awake, which also means neither frozen, static nor asleep: a body born resting, + // or one that has landed by the drain, opens no row. + if (!(coop::prop_wire_parity::PhysFlagsOf(actor) & pf::kSimulatePhysics)) return; coop::prop_drive_host::Coast(actor, "host birth"); } } // namespace From 7af182118e096e8bb174ad8f9566d21318dd5083 Mon Sep 17 00:00:00 2001 From: Shinobu2 <32747633+Shinobu2@users.noreply.github.com> Date: Tue, 6 Oct 2026 23:57:18 -0700 Subject: [PATCH 026/306] [coop] a client's opened point sack is paid by the host A client pressing a point sack ran its body locally: the points went to its own balance, the sack was destroyed on every peer, and the host's next balance row took the points away. The client now cancels the body and names the sack to the host (PointSackRedeem, kind 173), which judges reach from its own pose of the sender, destroys its copy and pays the sack's own points only once that copy is no longer live; a press while joining is held back and the sack stays. Build 220. The net of the sack hunks of the commits below. Co-Authored-By: Claude Opus 5.5 Co-Authored-By: Codex (cherry picked from commit 6401b2ca0b8f1166bf9eff83423b7d07bc9feee5) (cherry picked from commit 4a0501a47ed57a08d6293693437f3e7e3882fdbb) (cherry picked from commit bb02a9f5cb1ac297bb5027ac2a99d905a03b2c34) (cherry picked from commit e3b14fe2fed153035879a84a8acb2db86ab2e0ea) --- README.md | 2 +- docs/devices.md | 2 +- docs/props.md | 7 + .../include/coop/items/point_sack_intent.h | 34 ++++ src/votv-coop/include/coop/net/protocol.h | 15 +- src/votv-coop/sources.cmake | 1 + .../coop/dispatch/event_dispatch_intent.cpp | 14 ++ .../src/coop/interactables/verb_lanes.cpp | 4 + .../src/coop/items/point_sack_intent.cpp | 180 ++++++++++++++++++ 9 files changed, 256 insertions(+), 3 deletions(-) create mode 100644 src/votv-coop/include/coop/items/point_sack_intent.h create mode 100644 src/votv-coop/src/coop/items/point_sack_intent.cpp diff --git a/README.md b/README.md index dd8e4dcfa..8e0fa57bd 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ with its own signatures, drives the game's own classes and functions through ref where reflection cannot see. No asset edits, no repacked paks. Transport is GameNetworkingSockets: 13 unreliable pose and state streams, a voice stream beside -them, and one reliable ordered channel whose payloads are 161 message kinds. Each machine's engine +them, and one reliable ordered channel whose payloads are 162 message kinds. Each machine's engine re-derives animation, physics and rendering from the streamed state. The host is authoritative for world state, randomness and NPC simulation; a client acts by naming an intent that the host performs. diff --git a/docs/devices.md b/docs/devices.md index 677d9e658..e1f372743 100644 --- a/docs/devices.md +++ b/docs/devices.md @@ -521,7 +521,7 @@ an error line. |---|---| | A refused coin-gun sale has already destroyed the prop on the client, and no heal re-asserts it: a client authors that destruction before the arbiter answers, as its generator install does, whose refusal refunds the upgrade. The host rebuilds its key index periodically, so a refusal is rare | `[V]` `coop/items/coingun_sync` | | The coin collect has two entries; the interceptor sits on the overlap entry, and the E-press entry dispatches inside the Blueprint where it cannot fire, so a coin a client collects by pressing is credited on the client only and the host's next balance broadcast erases it | `[V]` `coop/items/coingun_sync` | -| A client's earnings from anything but the drone and the coin gun (a point sack, a chest, an achievement) reach only its own machine and are erased by the host's next broadcast | `[V]` `coop/world/balance_sync` is one-way | +| A client's earnings from a chest or an achievement reach only its own machine and are erased by the host's next broadcast | `[V]` `coop/world/balance_sync` is one-way | | A client's light-group index has been reported dropping to zero after a join; not reproduced | `[?]` [issue 11](https://github.com/VOTV-MP/Multivoid/issues/11) | | A slot change reaches the other peer on the next poll, so up to a second plus the round trip. A player who reaches a box inside that window acts on the slot as it was: an eject of a disc the other peer has just inserted answers "No floppy disc in the slot" and is not retried, and a client's insert into a slot the host has just filled replaces the host's disc when the claim lands, which loses that disc | `[V]` the lane polls at 1 Hz, and the host applies a claim over whatever its slot holds (`coop/interactables/floppy_slot_sync`); a faster poll would narrow the window rather than close it | diff --git a/docs/props.md b/docs/props.md index 06a213c90..f9407bd15 100644 --- a/docs/props.md +++ b/docs/props.md @@ -50,6 +50,13 @@ birth's own initialisation is too early for a record, and without the drain a di ejected reached every client blank. The drain logs its traffic once a minute (`HOST BIRTH RECORDS`). +Point-sack redemption is a client intent: the host authorizes reach, reserves the live UObject +incarnation across callbacks, destroys it, and pays only after that incarnation is no longer +live (`coop/items/point_sack_intent`). A successful dispatch with the sack still live pays +nothing. A joining client's action is also intercepted: a refused send leaves the sack intact +for another press after joining. `[?]` Concurrent redemption needs a two-peer test; a consumed sack whose payment fails +has no automatic recovery. + ### How a birth finds its local actor A birth names a key, so the receiver looks that key up first and, finding it, converges the actor diff --git a/src/votv-coop/include/coop/items/point_sack_intent.h b/src/votv-coop/include/coop/items/point_sack_intent.h new file mode 100644 index 000000000..f90833a74 --- /dev/null +++ b/src/votv-coop/include/coop/items/point_sack_intent.h @@ -0,0 +1,34 @@ +// coop/items/point_sack_intent.h -- a client's point sack, opened on the host. +// +// prop_pointSack_C (and its sized variants) pays on its own action: actionOptionIndex runs +// lib_C::addPoints(points), a hint naming the sum, and K2_DestroyActor on itself. On a client that +// credit lands only in the client's mirror of the shared balance, and the host's next balance row +// takes it away again, while the sack's destroy reaches the host and the points are lost for both. +// A running client's action on a sack is refused at the script-body gate and sent to the host as +// a PointSackRedeem naming the sack; the host checks the sender's reach, destroys its copy, +// and pays its own `points` only when that incarnation is no longer live. The sack is the +// consumption record: a repeated or late request finds no sack and pays nothing. A failed send +// during joining leaves the sack intact for retry. + +#pragma once + +#include + +namespace coop::net { +class Session; +struct PointSackRedeemPayload; +} + +namespace coop::point_sack_intent { + +void Install(coop::net::Session* session); + +// Settles the gate watch; game thread. +void Tick(); + +// HOST: a client's request. Game thread. +void OnRedeem(coop::net::Session& session, const coop::net::PointSackRedeemPayload& payload, uint8_t senderSlot); + +void OnDisconnect(); + +} // namespace coop::point_sack_intent diff --git a/src/votv-coop/include/coop/net/protocol.h b/src/votv-coop/include/coop/net/protocol.h index 1fea3247f..3106a4c86 100644 --- a/src/votv-coop/include/coop/net/protocol.h +++ b/src/votv-coop/include/coop/net/protocol.h @@ -30,7 +30,7 @@ inline constexpr uint32_t kMagic = 0x564D5450u; // This file is past the 1500-line hard cap and stays there: it is the single-feature exception the // rule names. One wire format, whose enum, payload structs and static_asserts are read together; // splitting it would put a kind's number in one file and its bytes in another. -inline constexpr uint16_t kProtocolVersion = 219; +inline constexpr uint16_t kProtocolVersion = 220; // Default LAN port (overridable via multivoid.ini "net.port="). inline constexpr uint16_t kDefaultPort = 47621; @@ -973,6 +973,10 @@ enum class ReliableKind : uint8_t { // read, the first five effect entries and the effect total. Never relayed. Late join: none. Trust: as // StatOrderReply. StatQueryReplyPayload. StatQueryReply = 172, + + // Client to host: my player opened this point sack; the host pays the sack's own points from its copy + // and destroys it, within the player's reach. Never relayed. PointSackRedeemPayload. + PointSackRedeem = 173, }; #pragma pack(push, 1) @@ -1825,6 +1829,15 @@ struct ServerRepairPayload { }; static_assert(sizeof(ServerRepairPayload) == 4, "ServerRepairPayload must be 4 bytes"); +// A client's opened point sack (PointSackRedeem): the sack by key, the eid as the keyless fallback. Nothing +// else is trusted: the host reads the amount from its own copy. +struct PointSackRedeemPayload { + WireKey key; // 32 -- the sack's save Key; len=0 -> keyless, resolve by eid + uint32_t elementId; // 4 -- the sack's Element id, 0 = none + uint32_t _pad; // 4 -- zeroed +}; +static_assert(sizeof(PointSackRedeemPayload) == 40, "PointSackRedeemPayload must be 40 bytes"); + // The server upgrades lane (ServerUpgradeState). Ops 0 (install) and 1 (take-out) name a box by its // servers[] index; op 2, the canonical, carries every box's level in servers[] order (a farm past 64 // boxes logs and caps, as ServerState does); op 3 refuses the author's op `refused` at `box`. diff --git a/src/votv-coop/sources.cmake b/src/votv-coop/sources.cmake index a4657b8bd..4e3bbcf29 100644 --- a/src/votv-coop/sources.cmake +++ b/src/votv-coop/sources.cmake @@ -231,6 +231,7 @@ set(VOTVCOOP_SOURCES src/coop/items/coingun_sync.cpp src/coop/items/coingun_arbiter.cpp src/coop/items/coingun_collect.cpp + src/coop/items/point_sack_intent.cpp src/coop/items/order_sync.cpp src/coop/items/order_queue_sync.cpp src/coop/world/firefly_sync.cpp diff --git a/src/votv-coop/src/coop/dispatch/event_dispatch_intent.cpp b/src/votv-coop/src/coop/dispatch/event_dispatch_intent.cpp index 718c64623..9d059a24d 100644 --- a/src/votv-coop/src/coop/dispatch/event_dispatch_intent.cpp +++ b/src/votv-coop/src/coop/dispatch/event_dispatch_intent.cpp @@ -25,6 +25,7 @@ #include "coop/interactables/desk_ping_sync.h" // a ping's verdict on the main desk, and its answers #include "coop/interactables/desk_verb_intent.h" // a press on the main desk's save family, and its answers #include "coop/items/coingun_sync.h" +#include "coop/items/point_sack_intent.h" // CLIENT->HOST an opened point sack #include "coop/items/order_queue_sync.h" // HOST->CLIENT the delivery order queue #include "coop/items/order_sync.h" #include "coop/props/pack_trash_intent.h" // CLIENT->HOST bagging of a pile or clump @@ -377,6 +378,19 @@ bool HandleIntentEvent(net::Session& session, static_cast(msg.senderPeerSlot)); break; } + case net::ReliableKind::PointSackRedeem: { + // CLIENT->HOST: a client opened a point sack; the host pays it from its copy and destroys it. Never relayed. + if (session.role() != net::Role::Host || msg.senderPeerSlot < 1 || msg.senderPeerSlot >= net::kMaxPeers || + msg.payloadLen < sizeof(net::PointSackRedeemPayload)) { + UE_LOGW("event_feed: PointSackRedeem dropped (role, sender slot %d or length %zu)", msg.senderPeerSlot, + static_cast(msg.payloadLen)); + break; + } + net::PointSackRedeemPayload p{}; + std::memcpy(&p, msg.payload, sizeof(p)); + coop::point_sack_intent::OnRedeem(session, p, static_cast(msg.senderPeerSlot)); + break; + } case net::ReliableKind::EraserPressIntent: { // CLIENT->HOST: a client pressed the drive eraser's delete button; the host tests the sender's reach, waits for // its slot to hold the named drive and presses it. HOST->CLIENT: what the host's eraser did, for this peer's diff --git a/src/votv-coop/src/coop/interactables/verb_lanes.cpp b/src/votv-coop/src/coop/interactables/verb_lanes.cpp index c4bd3b14d..88464c1ab 100644 --- a/src/votv-coop/src/coop/interactables/verb_lanes.cpp +++ b/src/votv-coop/src/coop/interactables/verb_lanes.cpp @@ -10,6 +10,7 @@ #include "coop/interactables/keypad_verbs.h" #include "coop/interactables/lightgroup_verbs.h" #include "coop/interactables/toggle_verbs.h" +#include "coop/items/point_sack_intent.h" #include "coop/net/session.h" #include "ue_wrap/core/walk_timer.h" // per-lane [WALK-TIME] attribution (diagnostic) @@ -25,6 +26,7 @@ void Install(coop::net::Session& session) { coop::toggle_verbs::Install(&session); // a symmetric device's state goes at the verb that writes it: each peer sends its own coop::keypad_verbs::Install(&session); // a keypad's verbs: the host sends each, a client's own entries run on the host coop::desk_verb_intent::Install(&session); // a client's press on the desk's save family is run by the host + coop::point_sack_intent::Install(&session); // a client's opened point sack is paid by the host } void Tick(coop::net::Session& session) { @@ -34,6 +36,7 @@ void Tick(coop::net::Session& session) { coop::door_state_verbs::Tick(); // settle the door state watches coop::lightgroup_verbs::Tick(); // settle the light group watch coop::toggle_verbs::Tick(); // settle the toggle verb watches + coop::point_sack_intent::Tick(); // settle the point sack watch coop::keypad_verbs::Tick(session); // settle the keypad watches; HOST: run queued keypad intents { ue_wrap::ScopedWalkTimer _w{"sync:desk_verb"}; coop::desk_verb_intent::Tick(session); } // settle its seams; HOST: run one queued desk press a tick a client } @@ -55,6 +58,7 @@ void OnDisconnect() { coop::toggle_verbs::OnDisconnect(); coop::keypad_verbs::OnDisconnect(); coop::desk_verb_intent::OnDisconnect(); + coop::point_sack_intent::OnDisconnect(); } } // namespace coop::verb_lanes diff --git a/src/votv-coop/src/coop/items/point_sack_intent.cpp b/src/votv-coop/src/coop/items/point_sack_intent.cpp new file mode 100644 index 000000000..d4ac28821 --- /dev/null +++ b/src/votv-coop/src/coop/items/point_sack_intent.cpp @@ -0,0 +1,180 @@ +// coop/items/point_sack_intent.cpp -- see coop/items/point_sack_intent.h. + +#include "coop/items/point_sack_intent.h" + +#include "coop/element/intent_authority.h" // IntentTarget: the sender's reach to the sack +#include "coop/element/registry.h" // LivePropActor, the eid fallback +#include "coop/net/protocol.h" +#include "coop/net/session.h" +#include "coop/net/wire_key_util.h" +#include "coop/player/players_registry.h" // kMaxPeers +#include "coop/props/prop_element_tracker.h" // GetPropElementIdForActor, FindLiveActorByKey + +#include "ue_wrap/actors/prop.h" // GetInteractableKeyString +#include "ue_wrap/core/game_thread.h" +#include "ue_wrap/core/cached_obj_ref.h" +#include "ue_wrap/core/log.h" +#include "ue_wrap/core/reflection.h" +#include "ue_wrap/core/script_gate.h" +#include "ue_wrap/engine/engine.h" // DestroyActor +#include "ue_wrap/world/economy.h" // AddPoints + +#include +#include +#include + +namespace coop::point_sack_intent { +namespace { + +namespace R = ue_wrap::reflection; +namespace sg = ue_wrap::script_gate; +namespace EL = coop::element; + +constexpr const wchar_t* kSackClass = L"prop_pointSack_C"; +constexpr const wchar_t* kVerb = L"actionOptionIndex"; +constexpr int kTag = 0x50534B31; // 'PSK1' +// The game's own reach for a prop action: mainPlayer.armLength, as the coin collect uses it. +constexpr float kReachUU = 200.0f; + +std::atomic g_session{nullptr}; +bool g_watchRegistered = false; +bool g_watchSettled = false; +uint64_t g_sent = 0, g_paid = 0, g_refused = 0; + +// Engine callbacks can redeem again before the outer destroy returns. +struct Redemption { + ue_wrap::CachedObjRef sack; + Redemption* previous; + static Redemption* active; + explicit Redemption(void* actor) : previous(active) { sack.Set(actor); active = this; } + ~Redemption() { active = previous; } + static bool Contains(void* actor) { + for (auto* p = active; p; p = p->previous) if (p->sack.Is(actor)) return true; + return false; + } +}; +Redemption* Redemption::active = nullptr; // game-thread stack + +bool IsSack(void* actor) { + if (!actor) return false; + for (void* cls = R::ClassOf(actor); cls; cls = R::SuperStructOf(cls)) + if (R::ToString(R::NameOf(cls)) == kSackClass) return true; + return false; +} + +// CLIENT: the sack's own action, refused and sent to the host. The host's own press, and a solo +// game's, run the game's body. +sg::Verdict OnActionPre(const sg::Call& c) { + auto* s = g_session.load(std::memory_order_acquire); + if (!s || !s->running() || s->role() != coop::net::Role::Client) return sg::Verdict::Run; + if (!IsSack(c.object)) return sg::Verdict::Run; + const std::wstring key = ue_wrap::prop::GetInteractableKeyString(c.object); + const EL::ElementId eid = coop::prop_element_tracker::GetPropElementIdForActor(c.object); + coop::net::PointSackRedeemPayload p{}; + if (!key.empty() && key != L"None") coop::net::WireKeyFromString(key, p.key); + p.elementId = eid == EL::kInvalidId ? 0u : static_cast(eid); + if (p.key.len == 0 && p.elementId == 0u) { + UE_LOGW("point_sack: this client's sack %p has neither a key nor an eid -- nothing to name; " + "left alone (the host's sack stays)", c.object); + return sg::Verdict::Cancel; + } + if (!s->SendReliableToSlot(0, coop::net::ReliableKind::PointSackRedeem, &p, sizeof(p))) { + UE_LOGW("point_sack: redeem request not sent -- sack left intact for retry"); + return sg::Verdict::Cancel; + } + ++g_sent; + UE_LOGI("point_sack: this client's sack key='%ls' eid=%u sent to the host (%llu sent) -- the host pays it", + key.c_str(), p.elementId, static_cast(g_sent)); + return sg::Verdict::Cancel; +} + +void Refuse(uint8_t slot, const std::wstring& key, uint32_t eid, const char* why) { + ++g_refused; + if (g_refused <= 20 || g_refused % 50 == 0) + UE_LOGI("point_sack: slot %u's sack key='%ls' eid=%u not paid (%s)", slot, key.c_str(), eid, why); +} + +} // namespace + +void Install(coop::net::Session* session) { + g_session.store(session, std::memory_order_release); +} + +void Tick() { + if (g_watchSettled) return; + if (!g_watchRegistered) { + g_watchRegistered = sg::WatchClassName(kSackClass, kVerb, kTag, &OnActionPre, nullptr); + if (!g_watchRegistered) { + g_watchSettled = true; + UE_LOGE("point_sack: the gate took no watch on %ls::%ls -- a client's sack pays only its own screen", + kSackClass, kVerb); + return; + } + } + sg::ResolvePendingNames(); + if (!sg::ClassNameWatchSettled(kSackClass, kVerb, kTag)) return; + g_watchSettled = true; + if (sg::ClassNameWatchLive(kSackClass, kVerb, kTag)) + UE_LOGI("point_sack: the watch on %ls::%ls is live", kSackClass, kVerb); + else + UE_LOGE("point_sack: the watch on %ls::%ls settled dead -- a client's sack pays only its own screen", + kSackClass, kVerb); +} + +void OnRedeem(coop::net::Session& session, const coop::net::PointSackRedeemPayload& p, uint8_t senderSlot) { + if (!ue_wrap::game_thread::IsGameThread()) return; + if (session.role() != coop::net::Role::Host) return; + const std::wstring key = coop::net::StringFromWireKey(p.key); + if (p.elementId != 0u && !EL::Registry::IsAllowedHostAllocatedEid(p.elementId) && + !EL::Registry::IsAllowedPeerAllocatedEid(p.elementId)) { + Refuse(senderSlot, key, p.elementId, "eid in neither band"); + return; + } + // Key first, as a sale resolves its prop: the eid is the keyless fallback. Index only, no walk. + // A named key that does not resolve is that sack gone; it never falls back to the eid, which may + // name another prop by now. Only a keyless sack is named by its eid. + void* sack = nullptr; + if (!key.empty()) sack = coop::prop_element_tracker::FindLiveActorByKey(key); + else if (p.elementId != 0u) sack = EL::LivePropActor(static_cast(p.elementId)); + // No sack is the ordinary end of a second request, or of two players opening one sack. + if (!sack) { Refuse(senderSlot, key, p.elementId, "no such sack here -- already opened"); return; } + if (!IsSack(sack)) { Refuse(senderSlot, key, p.elementId, "not a point sack"); return; } + if (Redemption::Contains(sack)) { Refuse(senderSlot, key, p.elementId, "redemption in progress"); return; } + const Redemption redemption(sack); + const auto tok = EL::IntentTarget::ForClientIntent(session, senderSlot, kReachUU); + if (const auto sub = tok.Authorize(sack); !sub) { + Refuse(senderSlot, key, p.elementId, EL::OutcomeName(sub.outcome)); + return; + } + if (!redemption.sack.Is(sack)) { Refuse(senderSlot, key, p.elementId, "sack no longer live"); return; } + static void* sCls = nullptr; + static int32_t sOffPoints = -1; + void* cls = R::ClassOf(sack); + if (cls != sCls) { sCls = cls; sOffPoints = R::FindPropertyOffset(cls, L"points"); } + if (sOffPoints < 0) { Refuse(senderSlot, key, p.elementId, "the sack's points did not resolve"); return; } + int32_t points = 0; + std::memcpy(&points, static_cast(sack) + sOffPoints, sizeof(points)); + // Call success is dispatch, not consumption. Pay only after this incarnation is dead. + ue_wrap::engine::DestroyActor(sack); + if (redemption.sack.Is(sack)) { + Refuse(senderSlot, key, p.elementId, "the sack is still live after destroy -- not paid"); + return; + } + if (!ue_wrap::economy::AddPoints(points)) { + UE_LOGW("point_sack: slot %u's sack key='%ls' was consumed but the payment of %d did not run", senderSlot, + key.c_str(), points); + return; + } + ++g_paid; + UE_LOGI("point_sack: HOST paid slot %u's sack key='%ls' eid=%u: %+d (%llu paid)", senderSlot, key.c_str(), + p.elementId, points, static_cast(g_paid)); +} + +void OnDisconnect() { + if (g_sent || g_paid || g_refused) + UE_LOGI("point_sack: session end -- sent=%llu paid=%llu refused=%llu", static_cast(g_sent), + static_cast(g_paid), static_cast(g_refused)); + g_sent = g_paid = g_refused = 0; +} + +} // namespace coop::point_sack_intent From eafcb58e69e6fbd0e91c66b89cb3ea9854f178e8 Mon Sep 17 00:00:00 2001 From: pelmentor Date: Sat, 10 Oct 2026 00:19:28 +0600 Subject: [PATCH 027/306] [coop] the point sack lane names mirrors and drops an unreached guard The client named a key-less sack by the tracker's locals-only id, which reads nothing for a mirror -- every sack loaded from the host's save or received as a spawn -- so such a sack could not be redeemed at all; it is named by the registry's reverse now. The host carried a stack guarding a redemption against itself through engine callbacks that nothing on its synchronous path reaches; it goes, keeping the one check that matters, that the sack is gone after the destroy before anything is paid. The kind states its late join and its trust, and the gate row is written. Co-Authored-By: Claude Opus 5.5 --- docs/coop-dispatch-visibility.md | 1 + docs/props.md | 5 ++- src/votv-coop/include/coop/net/protocol.h | 4 ++- .../src/coop/items/point_sack_intent.cpp | 31 ++++++------------- 4 files changed, 15 insertions(+), 26 deletions(-) diff --git a/docs/coop-dispatch-visibility.md b/docs/coop-dispatch-visibility.md index 8a26b6ddf..c3cce026e 100644 --- a/docs/coop-dispatch-visibility.md +++ b/docs/coop-dispatch-visibility.md @@ -126,6 +126,7 @@ Never spawn from an observer directly; post it. `[V]` | `signalDriveEraser_C::actionOptionIndex`, action 4, the eraser's delete (a 3 s `Delay`, then the wipe of the drive in that peer's slot) | Blueprint call | no, to the detour | the script-body gate watches it BY NAME, PRE: a client's press is refused and sent with the drive it saw seated; the host presses its own eraser once its slot holds that drive, and its POST sends the press's show to every client (`eraser_press_intent`) `[V]` for a reflected press (the drive drill); `[RD]` for the player's own button, which reaches the same function | | `serverBox_C::breakServer` and `serverBox_C::break_type`, a box's break (eight world events and a load reach the first: lightning, `addDamage`, `ticker_serverBreaker`, the server-breaker prop, three triggers, the dish's `loadData`; the desk's virus event the second) | Blueprint calls | no, to the detour | the script-body gate watches both BY NAME, PRE: a client refuses them through its whole connected session; the host's run, and its row carries the state (`serverbox_sync`) `[V]` (the server drill: a client's own breakServer refused) | | `serverBox_C::fix`, a box's repair (the gamemode's repair widget's `end(true)`, the Kerfur, `p_kerfus`) | Blueprint calls | no, to the detour | the script-body gate watches it BY NAME, PRE: on a client the repair widget's call is refused and sent to the host as a repair intent, which the host runs within reach, and every other caller's is refused (`serverbox_sync`) `[V]` (the server drill: the repair widget's fix refused on the client and run on the host) | +| `prop_pointSack_C::actionOptionIndex`, a player opening a point sack (any interface route) | Blueprint call | no, to the detour | the script-body gate watches it BY NAME on the sack's class, PRE, and refuses it on a client, which names the sack to the host; the host destroys its copy and pays the sack's points (`coop/items/point_sack_intent`) `[RD]` | | `lib_C::addPoints` from the repair widget (its `end(correct)` reward and record) | Blueprint call on `Default__lib_C` | no, to the detour | the script-body gate watches it BY NAME, PRE, and refuses it on a client when the caller is the gamemode's repair widget: the host pays that reward with the repair (`serverbox_sync`) `[RD]` | | `ExecuteUbergraph_signalDriveEraser` at entry 15, the eraser press's resume after its `Delay` | the latent action manager's call through ProcessEvent | no, to the detour | the script-body gate watches it BY NAME, POST, on the host: a cleared `processing` is a wipe, a set one a deny, and each client's eraser shows it (`eraser_press_intent`) `[V]` (the drive drill: the host's done shown on the client) | | `analogDScreenTest_C::formDownload`, the download's arm (`dishesStop`'s call when the dishes stop; the desk's load) | a local call from the desk's ubergraph | no, to the detour | the script-body gate watches it BY NAME: the host's POST sends the arm with its decoded and polarity; a client's PRE writes the host's values into its parameters while a replay runs, and refuses the verb outside one in the world it announced (`download_arm_sync`) `[V]` (the download drill: one formDownload body an arm, the host's polarity) | diff --git a/docs/props.md b/docs/props.md index f9407bd15..cf4d3a176 100644 --- a/docs/props.md +++ b/docs/props.md @@ -50,9 +50,8 @@ birth's own initialisation is too early for a record, and without the drain a di ejected reached every client blank. The drain logs its traffic once a minute (`HOST BIRTH RECORDS`). -Point-sack redemption is a client intent: the host authorizes reach, reserves the live UObject -incarnation across callbacks, destroys it, and pays only after that incarnation is no longer -live (`coop/items/point_sack_intent`). A successful dispatch with the sack still live pays +Point-sack redemption is a client intent: the host authorizes reach, destroys its copy, and pays +only after that incarnation is no longer live (`coop/items/point_sack_intent`). A successful dispatch with the sack still live pays nothing. A joining client's action is also intercepted: a refused send leaves the sack intact for another press after joining. `[?]` Concurrent redemption needs a two-peer test; a consumed sack whose payment fails has no automatic recovery. diff --git a/src/votv-coop/include/coop/net/protocol.h b/src/votv-coop/include/coop/net/protocol.h index 3106a4c86..c8900d75a 100644 --- a/src/votv-coop/include/coop/net/protocol.h +++ b/src/votv-coop/include/coop/net/protocol.h @@ -975,7 +975,9 @@ enum class ReliableKind : uint8_t { StatQueryReply = 172, // Client to host: my player opened this point sack; the host pays the sack's own points from its copy - // and destroys it, within the player's reach. Never relayed. PointSackRedeemPayload. + // and destroys it, within the player's reach. Never relayed. Late join: none -- the sack is its own + // record, gone once opened. Trust: the amount is the host's copy's and the reach the host's pose of the + // sender; the client names the sack and nothing else. PointSackRedeemPayload. PointSackRedeem = 173, }; diff --git a/src/votv-coop/src/coop/items/point_sack_intent.cpp b/src/votv-coop/src/coop/items/point_sack_intent.cpp index d4ac28821..bbd19080c 100644 --- a/src/votv-coop/src/coop/items/point_sack_intent.cpp +++ b/src/votv-coop/src/coop/items/point_sack_intent.cpp @@ -8,11 +8,11 @@ #include "coop/net/session.h" #include "coop/net/wire_key_util.h" #include "coop/player/players_registry.h" // kMaxPeers -#include "coop/props/prop_element_tracker.h" // GetPropElementIdForActor, FindLiveActorByKey +#include "coop/props/prop_element_tracker.h" // FindLiveActorByKey #include "ue_wrap/actors/prop.h" // GetInteractableKeyString -#include "ue_wrap/core/game_thread.h" #include "ue_wrap/core/cached_obj_ref.h" +#include "ue_wrap/core/hot_path_guard.h" // UE_ASSERT_GAME_THREAD #include "ue_wrap/core/log.h" #include "ue_wrap/core/reflection.h" #include "ue_wrap/core/script_gate.h" @@ -41,20 +41,6 @@ bool g_watchRegistered = false; bool g_watchSettled = false; uint64_t g_sent = 0, g_paid = 0, g_refused = 0; -// Engine callbacks can redeem again before the outer destroy returns. -struct Redemption { - ue_wrap::CachedObjRef sack; - Redemption* previous; - static Redemption* active; - explicit Redemption(void* actor) : previous(active) { sack.Set(actor); active = this; } - ~Redemption() { active = previous; } - static bool Contains(void* actor) { - for (auto* p = active; p; p = p->previous) if (p->sack.Is(actor)) return true; - return false; - } -}; -Redemption* Redemption::active = nullptr; // game-thread stack - bool IsSack(void* actor) { if (!actor) return false; for (void* cls = R::ClassOf(actor); cls; cls = R::SuperStructOf(cls)) @@ -69,7 +55,9 @@ sg::Verdict OnActionPre(const sg::Call& c) { if (!s || !s->running() || s->role() != coop::net::Role::Client) return sg::Verdict::Run; if (!IsSack(c.object)) return sg::Verdict::Run; const std::wstring key = ue_wrap::prop::GetInteractableKeyString(c.object); - const EL::ElementId eid = coop::prop_element_tracker::GetPropElementIdForActor(c.object); + // The registry's reverse names a mirror as well as a local: a sack loaded from the host's save or + // received as a spawn is a mirror here, and a key-less one is named by this alone. + const EL::ElementId eid = EL::Registry::Get().EidForActor(c.object); coop::net::PointSackRedeemPayload p{}; if (!key.empty() && key != L"None") coop::net::WireKeyFromString(key, p.key); p.elementId = eid == EL::kInvalidId ? 0u : static_cast(eid); @@ -122,7 +110,7 @@ void Tick() { } void OnRedeem(coop::net::Session& session, const coop::net::PointSackRedeemPayload& p, uint8_t senderSlot) { - if (!ue_wrap::game_thread::IsGameThread()) return; + UE_ASSERT_GAME_THREAD("point_sack_intent::OnRedeem"); if (session.role() != coop::net::Role::Host) return; const std::wstring key = coop::net::StringFromWireKey(p.key); if (p.elementId != 0u && !EL::Registry::IsAllowedHostAllocatedEid(p.elementId) && @@ -139,14 +127,11 @@ void OnRedeem(coop::net::Session& session, const coop::net::PointSackRedeemPaylo // No sack is the ordinary end of a second request, or of two players opening one sack. if (!sack) { Refuse(senderSlot, key, p.elementId, "no such sack here -- already opened"); return; } if (!IsSack(sack)) { Refuse(senderSlot, key, p.elementId, "not a point sack"); return; } - if (Redemption::Contains(sack)) { Refuse(senderSlot, key, p.elementId, "redemption in progress"); return; } - const Redemption redemption(sack); const auto tok = EL::IntentTarget::ForClientIntent(session, senderSlot, kReachUU); if (const auto sub = tok.Authorize(sack); !sub) { Refuse(senderSlot, key, p.elementId, EL::OutcomeName(sub.outcome)); return; } - if (!redemption.sack.Is(sack)) { Refuse(senderSlot, key, p.elementId, "sack no longer live"); return; } static void* sCls = nullptr; static int32_t sOffPoints = -1; void* cls = R::ClassOf(sack); @@ -155,8 +140,10 @@ void OnRedeem(coop::net::Session& session, const coop::net::PointSackRedeemPaylo int32_t points = 0; std::memcpy(&points, static_cast(sack) + sOffPoints, sizeof(points)); // Call success is dispatch, not consumption. Pay only after this incarnation is dead. + ue_wrap::CachedObjRef incarnation; + incarnation.Set(sack); ue_wrap::engine::DestroyActor(sack); - if (redemption.sack.Is(sack)) { + if (incarnation.Is(sack)) { Refuse(senderSlot, key, p.elementId, "the sack is still live after destroy -- not paid"); return; } From c7b5152f3eed835823c7f1cd37a8d047ec311a83 Mon Sep 17 00:00:00 2001 From: pelmentor Date: Sat, 10 Oct 2026 00:20:49 +0600 Subject: [PATCH 028/306] [coop] the host's spawn drain takes eight births a tick The drain expressed every queued birth in one tick, and each adopted one now also captures and sends its save record: a spawn-menu pile or a container's loot of a hundred props was one host frame of Blueprint dispatches, then a run of frames on every client landing the records on their eight-a-frame apply budget. The drain takes eight a tick, the rest waiting their turn in the queue, whose liveness check runs every tick. Co-Authored-By: Claude Opus 5.5 --- src/votv-coop/include/coop/props/host_spawn_watcher.h | 7 ++++--- src/votv-coop/src/coop/props/host_spawn_watcher.cpp | 11 +++++++++-- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/src/votv-coop/include/coop/props/host_spawn_watcher.h b/src/votv-coop/include/coop/props/host_spawn_watcher.h index efb08004f..10d7ac762 100644 --- a/src/votv-coop/include/coop/props/host_spawn_watcher.h +++ b/src/votv-coop/include/coop/props/host_spawn_watcher.h @@ -46,9 +46,10 @@ void Install(coop::net::Session* session); // and publish the save record of each one it adopted (prop_save_data::PublishHostBirth). A birth // that simulates and is awake, adopted here or tracked by the init seam before its finish, coasts // on the driven-prop stream until it rests (prop_drive_host::Coast), at most 32 at once. -// ONE express attempt per entry -- the queue is cleared unconditionally, so a still-unkeyed -// actor falls to the periodic safety census rather than being retried here. Host-side express; -// game thread (net-pump tick). +// ONE express attempt per entry, eight entries a tick -- an entry is spent when drained, so a +// still-unkeyed actor falls to the periodic safety census rather than being retried here, and a +// burst waits its turn in the queue rather than costing one frame. Host-side express; game thread +// (net-pump tick). void DrainPendingSpawns(coop::net::Session* session); // Per-tick death-watch. PEER-SYMMETRIC, like the broadcaster: each peer watches the ambient diff --git a/src/votv-coop/src/coop/props/host_spawn_watcher.cpp b/src/votv-coop/src/coop/props/host_spawn_watcher.cpp index d559ee751..5e0bd2702 100644 --- a/src/votv-coop/src/coop/props/host_spawn_watcher.cpp +++ b/src/votv-coop/src/coop/props/host_spawn_watcher.cpp @@ -394,7 +394,14 @@ void DrainPendingSpawns(coop::net::Session* s) { g_pendingFinished.clear(); return; } - for (const PendingFinishedSpawn& e : g_pendingFinished) { + // A burst -- a spawn-menu pile, a container's loot -- is drained a few births a tick: each adopted + // birth captures and sends its save record, one Blueprint dispatch and a serialise, and every client + // lands those records on its own 8-a-frame apply budget. The rest wait in the queue, which each tick + // re-checks for liveness at its top; a straggler not keyed yet gets those ticks to key. + constexpr size_t kDrainPerTick = 8; + const size_t take = g_pendingFinished.size() < kDrainPerTick ? g_pendingFinished.size() : kDrainPerTick; + for (size_t i = 0; i < take; ++i) { + const PendingFinishedSpawn& e = g_pendingFinished[i]; if (!e.actor || !R::IsLiveByIndex(e.actor, e.idx)) continue; // died before adopt // Marked as a mirror after our enqueue check: not ours. if (coop::prop_echo_suppress::IsMirrorSpawn(e.actor)) continue; @@ -427,7 +434,7 @@ void DrainPendingSpawns(coop::net::Session* s) { CoastIfFalling(e.actor); } } - g_pendingFinished.clear(); + g_pendingFinished.erase(g_pendingFinished.begin(), g_pendingFinished.begin() + static_cast(take)); } void OnDisconnect() { From e32e04436bd5c214d1f172848ae1f4a61489c453 Mon Sep 17 00:00:00 2001 From: pelmentor Date: Sat, 10 Oct 2026 00:20:49 +0600 Subject: [PATCH 029/306] [coop] an idle birth ledger is quiet, and a NaN best is no change The host's birth-record ledger printed its two lines every minute of every session, births or none; a minute without a birth now says nothing. A server best that reads as NaN never equals the last row sent, so the server lane would have broadcast its row every second: it goes out as no best instead. Co-Authored-By: Claude Opus 5.5 --- src/votv-coop/src/coop/interactables/serverbox_sync.cpp | 4 +++- src/votv-coop/src/coop/props/prop_save_data.cpp | 3 +++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/votv-coop/src/coop/interactables/serverbox_sync.cpp b/src/votv-coop/src/coop/interactables/serverbox_sync.cpp index a02677252..d90323e66 100644 --- a/src/votv-coop/src/coop/interactables/serverbox_sync.cpp +++ b/src/votv-coop/src/coop/interactables/serverbox_sync.cpp @@ -101,7 +101,9 @@ bool ReadState(coop::net::ServerStatePayload& p) { p.effCalc = agg.efficiencyCalc; p.effDownl = agg.efficiencyDownload; p.serverCount = static_cast(servers.size()); - if (!SB::ReadRepairBest(p.timeBest)) p.timeBest = 0.f; + // A best that does not read, or reads as no number, goes out as none: a NaN never equals the last + // row and would send one every poll. + if (!SB::ReadRepairBest(p.timeBest) || !std::isfinite(p.timeBest)) p.timeBest = 0.f; return true; } diff --git a/src/votv-coop/src/coop/props/prop_save_data.cpp b/src/votv-coop/src/coop/props/prop_save_data.cpp index b92e54943..28101c72d 100644 --- a/src/votv-coop/src/coop/props/prop_save_data.cpp +++ b/src/votv-coop/src/coop/props/prop_save_data.cpp @@ -180,6 +180,9 @@ void TickBirthLedger(uint64_t now) { if (!PT::SessionIsHost()) return; if (!g_birthWindowStartMs) { g_birthWindowStartMs = g_birthSessionStartMs = now; return; } if (now - g_birthWindowStartMs < kBirthReportMs) return; + // A minute with no host birth says nothing, and the session line under it has not moved. + const bool quiet = !g_birthWindow.total.records && !g_birthWindow.declined && !g_birthWindow.uncovered; + if (quiet) { g_birthWindowStartMs = now; return; } ReportBirthLedger("last minute", g_birthWindow, now - g_birthWindowStartMs); ReportBirthLedger("session so far", g_birthSession, now - g_birthSessionStartMs); g_birthWindow = BirthLedger{}; From f0ecd36e65cf80c2a8cc255499fd2826cd6f0115 Mon Sep 17 00:00:00 2001 From: pelmentor Date: Sat, 10 Oct 2026 00:21:24 +0600 Subject: [PATCH 030/306] [tools] the clock reads fresh on the game thread, and its latch frees The adopted snapshot served every caller, so a drill reading the clock on the game thread got a value up to a quarter second old, and after a world change one answer from the world left. A game-thread caller now reads the clock itself. The refresh latch was cleared only when the posted refresh returned, so a fault the pump absorbed would freeze the panel on its last snapshot; it is released however the refresh ends. Co-Authored-By: Claude Opus 5.5 --- src/votv-coop/include/coop/dev/set_clock.h | 5 +++-- src/votv-coop/src/coop/dev/set_clock.cpp | 10 +++++++--- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/votv-coop/include/coop/dev/set_clock.h b/src/votv-coop/include/coop/dev/set_clock.h index 42721777b..dcc9daafb 100644 --- a/src/votv-coop/include/coop/dev/set_clock.h +++ b/src/votv-coop/include/coop/dev/set_clock.h @@ -23,8 +23,9 @@ namespace coop::dev::set_clock { // Read the live NAMED clock (displayed day = timeZ.Z + 1, hour/minute from timeZ) + the sun // FRACTION (totalTime/MaxTime in [0,1)). Returns false if the world clock is not resolved yet. -// Any thread: a snapshot the game thread refreshes at most four times a second, so the first -// call after entering a world can return false once. +// The game thread reads the clock itself; any other thread reads a snapshot the game thread +// refreshes at most four times a second, which after a world change answers once for the world +// left until its first refresh runs. bool ReadCurrent(int& hourOut, int& minuteOut, int& dayOut, float& sunFracOut); // HOST-only (dev_gate): set the clock to (displayed day, hour, minute). Clamps day >= 1, hour diff --git a/src/votv-coop/src/coop/dev/set_clock.cpp b/src/votv-coop/src/coop/dev/set_clock.cpp index e0b77c393..6d6a972c6 100644 --- a/src/votv-coop/src/coop/dev/set_clock.cpp +++ b/src/votv-coop/src/coop/dev/set_clock.cpp @@ -57,12 +57,16 @@ void Refresh() { bool ReadCurrent(int& hourOut, int& minuteOut, int& dayOut, float& sunFracOut) { const uint64_t now = NowMs(); - if (now >= g_nextRefreshMs.load(std::memory_order_relaxed) && - !g_refreshPosted.exchange(true, std::memory_order_acq_rel)) { + if (GT::IsGameThread()) { + Refresh(); // a game-thread caller (a drill's tick) reads the clock itself, never a stale snapshot + } else if (now >= g_nextRefreshMs.load(std::memory_order_relaxed) && + !g_refreshPosted.exchange(true, std::memory_order_acq_rel)) { g_nextRefreshMs.store(now + 250, std::memory_order_relaxed); GT::Post([] { + // Released however the refresh ends: a fault the pump absorbs must not freeze the panel on its + // last snapshot for the rest of the run. + struct Release { ~Release() { g_refreshPosted.store(false, std::memory_order_release); } } release; Refresh(); - g_refreshPosted.store(false, std::memory_order_release); }); } std::lock_guard lk(g_snapMu); From d61a7dc415a85733f74737f37addea25b7db96a5 Mon Sep 17 00:00:00 2001 From: pelmentor Date: Sat, 10 Oct 2026 00:30:45 +0600 Subject: [PATCH 031/306] [coop] a driven prop's end edge only moves its generation forward too The yield was made advance-only, and the end edge still wrote whatever generation it carried: a late end of an older generation reopened one a hand had already taken, and the next end of that newer generation then found no record and parked the stale rest pose. Every write of the ended record now advances only, and an end older than the generation already closed on a never-parked prop touches nothing. Co-Authored-By: Claude Opus 5.5 --- src/votv-coop/src/coop/props/prop_drive_stream.cpp | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/votv-coop/src/coop/props/prop_drive_stream.cpp b/src/votv-coop/src/coop/props/prop_drive_stream.cpp index f6d6ace47..49b1be33f 100644 --- a/src/votv-coop/src/coop/props/prop_drive_stream.cpp +++ b/src/votv-coop/src/coop/props/prop_drive_stream.cpp @@ -283,14 +283,17 @@ void OnEnd(const coop::net::PropDriveEndPayload& p) { // Never parked here. The generation still closes, so a pose of it in flight cannot park // the prop now; the rest pose is applied if the prop is here, kept for it if not, and // dropped for a prop a hand holds -- that stream owns it -- or one a hand took this generation - // over from: the hand moved it since, so this pose is older than where it lies. + // over from: the hand moved it since, so this pose is older than where it lies. An end older than + // the generation already closed here is stale outright and touches nothing. + if (auto closed = g_endedGen.find(p.eid); closed != g_endedGen.end() && GenAfter(closed->second, p.gen)) + return; if (YieldedAtOrAfter(p.eid, p.gen)) { - g_endedGen[p.eid] = p.gen; + AdvanceGen(g_endedGen, p.eid, p.gen); UE_LOGI("[PROP-DRIVE] CLIENT END eid=%u gen=%u -- a hand took this generation over; " "closed, pose not applied", p.eid, static_cast(p.gen)); return; } - g_endedGen[p.eid] = p.gen; + AdvanceGen(g_endedGen, p.eid, p.gen); void* actor = coop::remote_prop::ResolveLiveActorByEid(p.eid); if (actor) { if (PR::IsDescendantOfProp(actor) && !HandOwns(actor)) { @@ -322,7 +325,7 @@ void OnEnd(const coop::net::PropDriveEndPayload& p) { } } g_drives.erase(it); - g_endedGen[p.eid] = p.gen; + AdvanceGen(g_endedGen, p.eid, p.gen); } bool IsParked(void* actor) { From 9e6147990c2b263ac22f7db73ab785b51cb05386 Mon Sep 17 00:00:00 2001 From: pelmentor Date: Sat, 10 Oct 2026 00:30:45 +0600 Subject: [PATCH 032/306] [dev] a disc selftest whose boxes never resolve ends, named A world without three empty-slot server boxes latched the selftest's box resolve off after its passes, and the peer then printed nothing more: no RESULT and no DONE, so a run waiting on the done line waited its budget out. That peer now ends the run there, with the reason, its RESULT FAIL and its DONE line, as an episode whose state never came does. Co-Authored-By: Claude Opus 5.5 --- src/votv-coop/src/coop/dev/floppy_selftest.cpp | 9 +++++++-- src/votv-coop/src/coop/dev/floppy_selftest_world.cpp | 2 ++ src/votv-coop/src/coop/dev/floppy_selftest_world.h | 4 +++- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/votv-coop/src/coop/dev/floppy_selftest.cpp b/src/votv-coop/src/coop/dev/floppy_selftest.cpp index 91936de21..043ebaedd 100644 --- a/src/votv-coop/src/coop/dev/floppy_selftest.cpp +++ b/src/votv-coop/src/coop/dev/floppy_selftest.cpp @@ -115,7 +115,7 @@ const Step kSteps[] = { { "R5-eject", true, 0, 6, Verb::Eject, "repeat 5 of the cross-peer eject" }, { "R6-insert", false, 0, 7, Verb::Insert, "repeat 6 of the cross-peer eject: insert" }, { "R6-eject", true, 0, 7, Verb::Eject, "repeat 6 of the cross-peer eject" }, - // The laptop, where a live run's disc came back blank under a new key (bug 19). Its slot + // The laptop, where a live run's disc came back blank under a new key (#39's report). Its slot // content crossed as one blob cut at 4 KB, so L1 has the client eject from its copy of a slot // past that size; L2 is the same disc size ejected by the host, whose copy is its own. { "L1-insert", true, kLaptop, 8, Verb::Insert, @@ -662,7 +662,12 @@ void Tick() { } if (g_finished) return; if (!FD::EnsureResolved()) return; // the disc fields come from there - if (!W::ResolveBoxes()) return; + if (!W::ResolveBoxes()) { + // A world without three empty-slot boxes can run no episode: said as this run's failure, with + // its RESULT and DONE lines, rather than a peer that never ends. + if (W::BoxesGaveUp()) Die(isHost, "boxes", "the world never held three empty-slot server boxes", now); + return; + } if (!g_seeded) { g_seeded = true; diff --git a/src/votv-coop/src/coop/dev/floppy_selftest_world.cpp b/src/votv-coop/src/coop/dev/floppy_selftest_world.cpp index 940e3295f..f66359118 100644 --- a/src/votv-coop/src/coop/dev/floppy_selftest_world.cpp +++ b/src/votv-coop/src/coop/dev/floppy_selftest_world.cpp @@ -227,6 +227,8 @@ int g_resolvePasses = 0; bool g_resolveLatchedOff = false; } // namespace +bool BoxesGaveUp() { return g_resolveLatchedOff; } + bool ResolveBoxes() { if (g_box[kTargets - 1].Raw()) return true; if (g_resolveLatchedOff) return false; diff --git a/src/votv-coop/src/coop/dev/floppy_selftest_world.h b/src/votv-coop/src/coop/dev/floppy_selftest_world.h index 7ca008bb3..fca5ed344 100644 --- a/src/votv-coop/src/coop/dev/floppy_selftest_world.h +++ b/src/votv-coop/src/coop/dev/floppy_selftest_world.h @@ -28,7 +28,7 @@ constexpr int kDiscs = 11; // Discs 8 and 9 carry this many rows of this width, which puts the laptop's slot content past the // 4 KB laptop_sync used to cut a slot blob at (the rows ride twice, in the save JSON and in -// floppyData): a live run lost a disc's content through that cut (bug 19). 31 is the laptop's own +// floppyData): a live run lost a disc's content through that cut (#39's report). 31 is the laptop's own // ceiling -- ui_laptop.updFloppy resizes floppyData to at most 31 rows -- so a bigger disc would // measure the game's trim, which is what 64 rows did on the first run. Every other disc has one row. constexpr int kBigFirst = 8; @@ -68,6 +68,8 @@ bool ReadLaptopSlot(BoxSlot& out); // world is up. False until every target is in hand; latches off with a line saying so rather than // walking the box list for the rest of the session. bool ResolveBoxes(); +// True once ResolveBoxes has latched off: the world never held the empty-slot boxes it needs. +bool BoxesGaveUp(); // The target box, live-checked, or null. Its label, for the log. void* Box(int index); From 7a9dec03bd310d404011abe35f1d3255636c9062 Mon Sep 17 00:00:00 2001 From: pelmentor Date: Sat, 10 Oct 2026 00:30:46 +0600 Subject: [PATCH 033/306] [coop] comments the audit of this wave found stale or unbacked The SAT console's context comment said a line waits for its dish; it is answered err. The record publisher's header had a sentence cut in two. Seven cites of "bug 19" had no referent here; they name pull request #39's report. The repair and server-row kinds now say what they carry and what the host trusts, and the repair floor says it is an assumption, since no solve of the eight puzzles has been timed. Co-Authored-By: Claude Opus 5.5 --- src/votv-coop/include/coop/net/protocol.h | 7 +++++-- src/votv-coop/include/coop/props/prop_save_data.h | 6 +++--- src/votv-coop/src/coop/interactables/floppy_slot_entry.cpp | 2 +- src/votv-coop/src/coop/interactables/floppy_slot_sync.cpp | 4 ++-- src/votv-coop/src/coop/interactables/sat_console_sync.cpp | 6 +++--- src/votv-coop/src/coop/interactables/serverbox_sync.cpp | 3 ++- src/votv-coop/src/coop/props/host_spawn_watcher.cpp | 2 +- 7 files changed, 17 insertions(+), 13 deletions(-) diff --git a/src/votv-coop/include/coop/net/protocol.h b/src/votv-coop/include/coop/net/protocol.h index c8900d75a..a8a332c57 100644 --- a/src/votv-coop/include/coop/net/protocol.h +++ b/src/votv-coop/include/coop/net/protocol.h @@ -499,7 +499,8 @@ enum class ReliableKind : uint8_t { PropDropIntent = 90, // Host to all, on change at about 1 Hz and to a joiner: the signal-server simulation (broken - // mask and aggregates); the client writes the state and re-skins. ServerStatePayload. + // mask and aggregates) and the repair record the host pays against; the client writes the state + // and re-skins. ServerStatePayload. ServerState = 91, // Host to all: the roach infestation as a paged snapshot; the client applies by ordinal. @@ -926,7 +927,9 @@ enum class ReliableKind : uint8_t { // Client to host: my player finished the repair minigame on this server box, by its servers[] index; the // host runs the box's fix when it is broken and within the player's reach, and a refusal is answered to the - // presser alone with the state row (ServerState). Never relayed. ServerRepairPayload. + // presser alone with the state row (ServerState). It carries the minigame's lol mode and solve time, the + // client's own report, which the host pays the reward from; a time under a second settles no record. + // Never relayed. ServerRepairPayload. ServerRepair = 164, // Host to client: one server-scope setting of the session, a row named by its KEY (as Source names a diff --git a/src/votv-coop/include/coop/props/prop_save_data.h b/src/votv-coop/include/coop/props/prop_save_data.h index d5c6eafc8..b1d1610bb 100644 --- a/src/votv-coop/include/coop/props/prop_save_data.h +++ b/src/votv-coop/include/coop/props/prop_save_data.h @@ -43,16 +43,16 @@ bool Covers(void* actor); // PropSaveDataIntent to the host, whose re-publish is also the acknowledgement. False when the class // is not covered, no peer could receive it, the capture fails, or the send did not take every chunk. // A caller may ignore it: a refused send is registered and re-sent from the live actor on a widening -// The record-as-payload precedent is named in ue_wrap/actors/save_record.h, on CaptureRecord. // backoff, and only a give-up past that ceiling is logged as a divergence. `bytesOut`, when given, -// receives the body size of a record that was built, sent or not. +// receives the body size of a record that was built, sent or not. The record-as-payload precedent +// is named in ue_wrap/actors/save_record.h, on CaptureRecord. bool Publish(coop::net::Session* s, void* actor, const std::wstring& key, size_t* bytesOut = nullptr); // HOST: the record of a prop this host just gave birth to at runtime -- a device's eject, a drop, a // place -- published from the seam that expresses it the tick after FinishSpawningActor, when the // birth has finished. The spawn row carries identity only, so without this every such prop reached -// the clients at its class default: a disc the host ejected arrived blank (bug 19). The path keeps +// the clients at its class default: a disc the host ejected arrived blank (#39's report). The path keeps // its own traffic ledger (records, bytes, the top classes), printed every 60 s and at teardown. bool PublishHostBirth(coop::net::Session* s, void* actor); diff --git a/src/votv-coop/src/coop/interactables/floppy_slot_entry.cpp b/src/votv-coop/src/coop/interactables/floppy_slot_entry.cpp index e17ba87ce..bedd42a4a 100644 --- a/src/votv-coop/src/coop/interactables/floppy_slot_entry.cpp +++ b/src/votv-coop/src/coop/interactables/floppy_slot_entry.cpp @@ -172,7 +172,7 @@ Transit* FindTransit(void* actor, uint64_t now) { // one disc, from the peer that only watched, its slot lane claiming it and its destroy relayed, and // the host keeping whichever claim landed last. A live run showed it: a client's laptop swallowed the // host's held disc at the moment the host inserted it, and a third peer's claim of its own copy -// replaced the host's slot (bug 19). The holder's own insert is the one that counts, so a disc +// replaced the host's slot (#39's report). The holder's own insert is the one that counts, so a disc // under another peer's drive is refused on every peer, the host included; every other settled disc // is let in, and each case is said, capped, since a slot takes a disc rarely. constexpr unsigned long long kMaxSettledLines = 64; diff --git a/src/votv-coop/src/coop/interactables/floppy_slot_sync.cpp b/src/votv-coop/src/coop/interactables/floppy_slot_sync.cpp index 17a1f2451..f2474cd8d 100644 --- a/src/votv-coop/src/coop/interactables/floppy_slot_sync.cpp +++ b/src/votv-coop/src/coop/interactables/floppy_slot_sync.cpp @@ -651,7 +651,7 @@ void OnChunk(const coop::net::BlobChunkPayload& p, uint8_t senderSlot) { in.st.floppyType); return; } - // [bug 19] What the claim replaces: an occupied slot overwritten by a claim naming another + // [#39's report] What the claim replaces: an occupied slot overwritten by a claim naming another // disc is a second author of this slot, which is how a live run lost a disc's content. Slot prev; const bool held = ReadSlotOf(kind, devices[index], prev) && prev.st.floppyType >= 0; @@ -699,7 +699,7 @@ void OnChunk(const coop::net::BlobChunkPayload& p, uint8_t senderSlot) { if (index >= n || !devices[index] || !R::IsLive(devices[index])) { ++skipped; continue; } if (!FS::IsSlotType(in.st.floppyType)) { ++skipped; continue; } g_awaiting.erase(ShadowKey(kind, index)); - // [bug 19] What this write replaces. A live run applied two inserts of one disc within a + // [#39's report] What this write replaces. A live run applied two inserts of one disc within a // second, the second from a peer that had only watched, and the host kept the later one. // The rows and the JSON are read only behind an occupied slot, as ApplySlot reads them. FS::Scalars prevSt{}; diff --git a/src/votv-coop/src/coop/interactables/sat_console_sync.cpp b/src/votv-coop/src/coop/interactables/sat_console_sync.cpp index 2239948cf..ad93e1c41 100644 --- a/src/votv-coop/src/coop/interactables/sat_console_sync.cpp +++ b/src/votv-coop/src/coop/interactables/sat_console_sync.cpp @@ -331,9 +331,9 @@ Terminal* TerminalFor(uint8_t slot, const std::string& guid) { return &g_terms.back(); } -// Whether a terminal stands in the context a line names. A dish index that does not resolve is not -// ROOT: the line waits for its dish rather than running against none, and nothing is kept, so the -// next line with the same index tries again. An index past the host's dishes is no dish at all. +// Whether a terminal stands in the context a line names. A dish index that does not resolve yet, or a +// context whose init fails, answers the line err rather than running it against none; nothing is kept, +// so the next line with the same index tries again. An index past the host's dishes is no dish at all. enum class Context { Ready, NotYet, Bad }; Context ApplyContext(Terminal& t, int32_t dish, const std::wstring& name) { diff --git a/src/votv-coop/src/coop/interactables/serverbox_sync.cpp b/src/votv-coop/src/coop/interactables/serverbox_sync.cpp index d90323e66..f1163b2bd 100644 --- a/src/votv-coop/src/coop/interactables/serverbox_sync.cpp +++ b/src/votv-coop/src/coop/interactables/serverbox_sync.cpp @@ -241,7 +241,8 @@ sg::Verdict OnRewardPre(const sg::Call& call) { // The lol mode and the solve time are the client's report: the host cannot see the minigame, so it bounds what it // takes (bounds apply to clients). A time under this settles no record and leaves the host's best alone -- one // impossible time written there would sit out of every honest solve's reach for the life of the save. One second -// is a bound chosen to sit under any solve by hand, not a measured minimum. +// is an assumption, not a measurement: no solve of the eight puzzles has been timed, and the bound only has to +// sit under the fastest honest one. constexpr uint16_t kMinSolveDs = 10; // HOST: the reward of a client's repair the host just ran, as the widget computes it, from the host's own diff --git a/src/votv-coop/src/coop/props/host_spawn_watcher.cpp b/src/votv-coop/src/coop/props/host_spawn_watcher.cpp index 5e0bd2702..73f4ce534 100644 --- a/src/votv-coop/src/coop/props/host_spawn_watcher.cpp +++ b/src/votv-coop/src/coop/props/host_spawn_watcher.cpp @@ -428,7 +428,7 @@ void DrainPendingSpawns(coop::net::Session* s) { // sets its state AFTER FinishSpawningActor, in the same Blueprint call: a device's // eject loadDatas the disc from its slot JSON and writes its rows and read-writes // there. That call has returned by this drain, so the record is the real one. Without - // it every client got the class default -- bug 19's run 1: nine of nine discs the + // it every client got the class default -- #39's report, run 1: nine of nine discs the // host ejected read EMPTIED on the client and INTACT on the host. coop::prop_save_data::PublishHostBirth(s, e.actor); CoastIfFalling(e.actor); From c999c516125b8a85958b363acaeaf1a2d66910ed Mon Sep 17 00:00:00 2001 From: Shinobu2 Date: Sun, 4 Oct 2026 17:14:41 -0700 Subject: [PATCH 034/306] [coop] a thrown container keeps its contents across peers A throw rebinds the container to its slot in the thrower's own array and fires no add or take, and the drop intent carries no contents, so the host built a client's thrown container empty, refused every later slice from the client for its base, and its re-published truth emptied the client's copy too. The thrower now ships its slice once the host's echo binds the container, marked as a birth; the host holds its copy as a transfer in progress, takes that author's slices unjudged, merges what it added meanwhile, and ends the transfer when the author leaves. A container the host births publishes its contents when the birth reaches the spawn drain. The net of the three commits below, with the container half of the fourth. Co-Authored-By: Claude Opus 5.5 (cherry picked from commit db42ac6d27f532f5b736919992badb37aeae8c0b) (cherry picked from commit adbb9c8c574a53ae715f7b2a85830add86e6c53e) (cherry picked from commit 0de523797c8a82d169a78f450572ea34a6c69fa6) (cherry picked from commit d0421a78e177c72373e6989fd70a35f288dec436) --- docs/devices.md | 13 ++ .../coop/props/container_contents_sync.h | 16 ++ .../include/coop/props/container_slice_wire.h | 6 + .../coop/props/container_contents_sync.cpp | 197 +++++++++++++++++- .../src/coop/props/host_spawn_watcher.cpp | 4 + .../src/coop/props/prop_drop_intent.cpp | 6 +- src/votv-coop/src/coop/session/subsystems.cpp | 1 + 7 files changed, 233 insertions(+), 10 deletions(-) diff --git a/docs/devices.md b/docs/devices.md index e1f372743..d0d3f8d39 100644 --- a/docs/devices.md +++ b/docs/devices.md @@ -108,6 +108,19 @@ waits in the same pen rather than being turned down, since refusing it would thr edit at exactly the moment the game is least able to judge it. A client's pen is bounded per author; the host's own slices are not bounded, the way nothing of the host's is. +A container that is thrown changes hands without either verb: the game rebinds the thrown actor to +its old slot of the thrower's own array, and the drop intent carries no contents, so the host's copy +of a client's throw was born empty and every later client slice for it was refused for its base. +The host's copy of a client's throw is now a transfer in progress: it is not published, to a joiner +either, until the thrower's slice lands. The thrower ships its slice once the host's echo binds the +container, and retries while its own slot is not readable yet; every slice of that author for the +container is the newest version of the transfer, an edit made while the first one waited included, +and is taken without a base. Whatever the host put in meanwhile is kept beside it, and the merged +contents go to every peer, the thrower included; if the two do not fit one container, nothing is +written and both copies keep what they hold. The wait ends after thirty seconds or when the +thrower leaves. A container the host throws publishes its contents at the express, since the +mirrors are born empty, retrying while its slot is not readable. + Applying a slice raw-writes the receiver's own array slot and then re-derives everything a setter owns through the game's own verbs -- the volume and mass recalculation and the display-name rebuild -- rather than writing those fields directly. The add verb cannot be the apply verb: it diff --git a/src/votv-coop/include/coop/props/container_contents_sync.h b/src/votv-coop/include/coop/props/container_contents_sync.h index 5076c1921..1538144fb 100644 --- a/src/votv-coop/include/coop/props/container_contents_sync.h +++ b/src/votv-coop/include/coop/props/container_contents_sync.h @@ -51,6 +51,22 @@ void QueueConnectBroadcastForSlot(int peerSlot); // the TTL runs as a leak guard only. Called from the client-side snapshot dispatch. void NoteJoinSnapshotBracket(bool open); +// A container that changed hands through the world: its contents are in this peer's own array, +// bound by the game's loadData, and no verb fired to mark it. Game thread. +// +// CLIENT, prop_drop_intent, at the drop intent for a container it threw: once the host's echo binds +// the actor, its slice goes to the host as a birth. +void NoteAuthoredBirth(void* actor); +// HOST, prop_drop_intent, at the spawn from a client's intent: the copy is empty and a transfer in +// progress. It is not published while awaited; the author's slice is taken without a base, merged +// with whatever the host put in meanwhile, and published to every peer. +void ExpectBirthSlice(void* actor, uint8_t authorSlot); +// HOST, host_spawn_watcher, at the express of the host's own birth: a container with contents +// publishes them, since the mirrors are born empty. One awaited from a client says nothing. +void NoteHostBirth(void* actor); +// HOST: a transfer awaited from this slot ends with it. Game thread. +void OnPeerGone(uint8_t slot); + void OnDisconnect(); // ---- dev-instrument seams (coop/dev/container_selftest) ------------------------ diff --git a/src/votv-coop/include/coop/props/container_slice_wire.h b/src/votv-coop/include/coop/props/container_slice_wire.h index 295ac510e..b4fb2fca2 100644 --- a/src/votv-coop/include/coop/props/container_slice_wire.h +++ b/src/votv-coop/include/coop/props/container_slice_wire.h @@ -26,6 +26,12 @@ namespace coop::props::container_slice_wire { // the transport ceiling, and a truncated blob is a silent lie. Real containers hold single digits. inline constexpr size_t kMaxRecords = 512; +// The base a client sends with the first slice of a container it threw into the world: the host +// built its copy from the client's intent and has published nothing for it, so there is no truth to +// have edited from. The host decides by the transfer it awaits from that author, not by this value +// (container_contents_sync); a host that awaits nothing judges it like any stale base. +inline constexpr uint64_t kBirthBase = ~0ull; + std::vector Pack(uint32_t eid, uint64_t baseHash, const std::vector& recs); diff --git a/src/votv-coop/src/coop/props/container_contents_sync.cpp b/src/votv-coop/src/coop/props/container_contents_sync.cpp index ffb83c0b3..73ae8132d 100644 --- a/src/votv-coop/src/coop/props/container_contents_sync.cpp +++ b/src/votv-coop/src/coop/props/container_contents_sync.cpp @@ -94,6 +94,27 @@ std::set g_retry; // it too, harmlessly; the drain's own gates filter those. std::atomic g_takeObjInFlight{false}; +// Births: a container thrown into the world, whose contents no verb marked. A client holds the actor +// it threw until the host's echo binds it, then ships its slice as a birth. The host's copy, built +// empty from the intent, is a transfer in progress until the author's slice lands: it is not +// published, and every slice of that author for it is the newest version of the transfer. Both +// sides expire, so a lost intent leaves nothing behind. +constexpr uint64_t kBirthTtlMs = 30000; +constexpr size_t kMaxBirths = 64; +struct Birth { + void* actor = nullptr; + int32_t idx = -1; + uint8_t authorSlot = 0; // host: the author it awaits + uint64_t deadlineMs = 0; +}; +std::vector g_authoredBirths; // client +std::set g_birthEids; // client: bound births whose slice is owed +std::vector g_awaitedBirths; // host +// A first publication owed for an eid -- a client's birth, the host's own thrown container -- whose +// slot may not be readable yet: an unreadable read is retried until the deadline instead of being +// taken as nothing to send. +std::map g_owedFirst; + uint64_t NowMs() { return static_cast( std::chrono::duration_cast( @@ -220,7 +241,7 @@ bool CarriesForeignIndex(const SR::SaveRecord& r) { return !r.ints.empty() && !r.ints[0].empty() && r.ints[0][0] != -1; } -bool ReadContents(void* inv, std::vector& out) { +bool ReadContents(void* inv, std::vector& out, bool neuterNested = true) { uint8_t* slot = GObjStackSlot(inv); if (!slot) return false; const SR::Arr objs = SR::ReadArr(slot, 0); // struct_mObject.obj @ +0 @@ -234,7 +255,7 @@ bool ReadContents(void* inv, std::vector& out) { for (int32_t i = 0; i < objs.num; ++i) { SR::SaveRecord r; SR::ReadSaveRecord(objs.data + static_cast(i) * SR::kSaveStride, r); - if (RecordIsNestedContainer(r)) NeuterNestedIndex(r); + if (neuterNested && RecordIsNestedContainer(r)) NeuterNestedIndex(r); out.push_back(std::move(r)); } return true; @@ -249,17 +270,31 @@ void RederiveManagedState(void* owner, void* inv); bool BroadcastContainer(coop::net::Session* s, uint32_t eid, void* inv, int toSlot, bool force) { std::vector recs; - if (!ReadContents(inv, recs)) return true; // nothing resolvable -- not a transport failure + if (!ReadContents(inv, recs)) { + // Nothing resolvable is not a transport failure -- unless a first publication is owed for this + // eid, which a slot not readable yet would otherwise lose for good. + auto it = g_owedFirst.find(eid); + if (it == g_owedFirst.end()) return true; + if (NowMs() < it->second) return false; + g_owedFirst.erase(it); + g_birthEids.erase(eid); + UE_LOGW("container_contents: eid=%u -- its contents never became readable; the first " + "publication is given up", eid); + return true; + } // The base being edited from: for a client the last host truth it applied; the host authors // from its own state and sends 0. uint64_t baseHash = 0; - if (!IsHost()) { + const bool birth = !IsHost() && toSlot < 0 && g_birthEids.count(eid) != 0; + if (birth) { + baseHash = cw::kBirthBase; + } else if (!IsHost()) { auto it = g_baseHash.find(eid); if (it != g_baseHash.end()) baseHash = it->second; } const std::vector blob = cw::Pack(eid, baseHash, recs); const uint64_t h = cw::ContentHash(eid, recs); - if (!force) { + if (!force && !birth) { auto it = g_sentHash.find(eid); if (it != g_sentHash.end() && it->second == h) return true; // unchanged -- say nothing } @@ -274,6 +309,8 @@ bool BroadcastContainer(coop::net::Session* s, uint32_t eid, void* inv, int toSl : coop::blob_chunks::SendBlobToSlot(s, toSlot, coop::net::ReliableKind::ContainerContents, g_nextSeq++, blob); if (ok) { + if (birth) g_birthEids.erase(eid); + if (toSlot < 0) g_owedFirst.erase(eid); if (toSlot < 0) g_sentHash[eid] = h; // only a FAN-OUT establishes what every peer has // A client's own accepted slice IS the host's next published truth, and the author is // deliberately excluded from the relay that carries it -- so it advances its base here. @@ -292,10 +329,11 @@ bool BroadcastContainer(coop::net::Session* s, uint32_t eid, void* inv, int toSl // author -- changed nothing on this peer, so it re-derives nothing; the seed sends every // container in the world in one frame. if (toSlot < 0) RederiveManagedState(OwnerOf(inv), inv); - UE_LOGI("container_contents: eid=%u shipped %zu records (%zu B)%s%s", + UE_LOGI("container_contents: eid=%u shipped %zu records (%zu B)%s%s%s", eid, recs.size(), blob.size(), toSlot < 0 ? "" : " [targeted]", - IsHost() ? "" : " [client-authored]"); + IsHost() ? "" : " [client-authored]", + birth ? " [birth]" : ""); } return ok; } @@ -320,6 +358,56 @@ void RelayToOthers(coop::net::Session* s, uint8_t authorSlot, const std::vector< } } +// The awaited birth for this eid, from this author or (authorSlot 0) from any, or null. The host never +// publishes an awaited container, so nothing a slice could have been based on exists yet. +Birth* AwaitedBirth(uint32_t eid, uint8_t authorSlot) { + void* actor = LivePropActor(eid); + if (!actor) return nullptr; + const uint64_t now = NowMs(); + for (Birth& b : g_awaitedBirths) + if (b.actor == actor && (authorSlot == 0 || b.authorSlot == authorSlot) && now < b.deadlineMs && + R::IsLiveByIndex(b.actor, b.idx)) + return &b; + return nullptr; +} + +void DropAwaitedBirth(uint32_t eid) { + void* actor = LivePropActor(eid); + if (!actor) return; + for (size_t i = 0; i < g_awaitedBirths.size(); ++i) { + if (g_awaitedBirths[i].actor != actor) continue; + g_awaitedBirths.erase(g_awaitedBirths.begin() + static_cast(i)); + return; + } +} + +// Client: a thrown container the host's echo has bound is marked, its slice owed as a birth. Host: +// an awaited birth that never came is dropped, and said. +void SweepBirths(uint64_t now) { + for (size_t i = 0; i < g_authoredBirths.size();) { + const Birth& b = g_authoredBirths[i]; + if (!R::IsLiveByIndex(b.actor, b.idx) || now >= b.deadlineMs) { + g_authoredBirths.erase(g_authoredBirths.begin() + static_cast(i)); + continue; + } + const auto eid = coop::element::Registry::Get().EidForActor(b.actor); + if (eid == coop::element::kInvalidId) { ++i; continue; } + g_birthEids.insert(static_cast(eid)); + g_owedFirst[static_cast(eid)] = b.deadlineMs; + g_dirty.insert(static_cast(eid)); + g_authoredBirths.erase(g_authoredBirths.begin() + static_cast(i)); + } + for (size_t i = 0; i < g_awaitedBirths.size();) { + const Birth& b = g_awaitedBirths[i]; + const bool live = R::IsLiveByIndex(b.actor, b.idx); + if (live && now < b.deadlineMs) { ++i; continue; } + if (live) + UE_LOGW("container_contents: the contents of a container slot %u threw never came -- it " + "stays as the host built it", static_cast(b.authorSlot)); + g_awaitedBirths.erase(g_awaitedBirths.begin() + static_cast(i)); + } +} + // The dirty drain. void DrainDirty(coop::net::Session* s) { @@ -340,7 +428,15 @@ void DrainDirty(coop::net::Session* s) { void* actor = LivePropActor(eid); if (!actor || !IsContainerActor(actor)) continue; void* inv = InventoryOf(actor); - if (!inv || !IsWorldContainerInventory(inv)) continue; // BOUNDARY 1 (fail-closed) + if (!inv || !IsWorldContainerInventory(inv)) { // BOUNDARY 1 (fail-closed) + // A first publication owed for it waits for the component instead of ending here. + auto owed = g_owedFirst.find(eid); + if (owed != g_owedFirst.end() && NowMs() < owed->second) g_retry.insert(eid); + continue; + } + // A transfer in progress is not published: what the host added to it so far merges into the + // author's slice when it lands, and is published with it. + if (IsHost() && AwaitedBirth(eid, 0)) { g_retry.insert(eid); continue; } if (!BroadcastContainer(s, eid, inv, -1, /*force=*/false)) g_retry.insert(eid); } } @@ -443,9 +539,14 @@ Ingest ParseAndApply(const std::vector& blob, uint32_t& outEid, uint8_t size_t o = 0; uint64_t baseHash = 0; if (!cw::ParseHeader(blob, o, outEid, baseHash)) return Ingest::Handled; + // A container a client threw: the host built its copy empty from the intent and has published + // nothing for it, so any slice of that author is the newest version of the transfer, whatever + // base it names -- an edit made while the first slice waited in the pen included. There is no + // reach to measure either: the throw put it where it is. + const bool birth = IsHost() && senderSlot != 0 && AwaitedBirth(outEid, senderSlot) != nullptr; // Host arbitration before anything is touched; a refusal is answered by re-publishing the // host's truth to the author, so it converges instead of sitting on a divergent view. - if (IsHost() && senderSlot != 0) { + if (IsHost() && senderSlot != 0 && !birth) { auto* s = g_session.load(std::memory_order_acquire); // No session, no arbitration, and a client slice is never applied unjudged: the refusal // that cannot be explained is still a refusal. @@ -491,6 +592,26 @@ Ingest ParseAndApply(const std::vector& blob, uint32_t& outEid, uint8_t UE_LOGW("container_contents: eid=%u -- %zu nested-container record(s) arrived carrying a " "foreign GObjStack index; neutered before the write", outEid, foreignIndices); } + // What the host put into its copy while the transfer was in flight. That copy was born empty, so + // all of it is the host's own and is kept beside the author's: replacing it would lose the host's + // item, refusing the slice would lose the author's. Read raw: these records stay in this array. + bool merged = false; + if (birth) { + void* actor = LivePropActor(outEid); + void* inv = actor && IsContainerActor(actor) ? InventoryOf(actor) : nullptr; + std::vector hostRecs; + if (inv && ReadContents(inv, hostRecs, /*neuterNested=*/false) && !hostRecs.empty()) { + if (recs.size() + hostRecs.size() > cw::kMaxRecords) { + // Neither half may be dropped to make the other fit: nothing is written, and the + // transfer stays awaited, so both copies keep what they hold. + UE_LOGW("container_contents: eid=%u -- the host's %zu record(s) do not fit beside the " + "thrower's %zu; the transfer is not applied", outEid, hostRecs.size(), recs.size()); + return Ingest::Handled; + } + recs.insert(recs.end(), hostRecs.begin(), hostRecs.end()); + merged = true; + } + } const uint64_t contentHash = cw::ContentHash(outEid, recs); const Ingest outcome = ApplyContents(outEid, recs, contentHash); // Host, client-authored and accepted. Two records, and the second one was missing: g_sentHash @@ -500,6 +621,16 @@ Ingest ParseAndApply(const std::vector& blob, uint32_t& outEid, uint8_t // moved on, and the second peer to edit a container was refused for being as up to date as the // first. if (outcome == Ingest::Applied && IsHost() && senderSlot != 0) { + if (birth) DropAwaitedBirth(outEid); + if (merged) { + // The author lacks the host's part: the merged contents go to every peer, the author + // included, by the host's own fan-out on the next sweep. + g_sentHash.erase(outEid); + g_dirty.insert(outEid); + UE_LOGI("container_contents: eid=%u slot %u transfer MERGED with the host's own records -- " + "published to all on the next sweep", outEid, static_cast(senderSlot)); + return outcome; + } g_sentHash[outEid] = contentHash; wp::NotePublished(outEid, contentHash); UE_LOGI("container_contents: eid=%u slot %u ACCEPTED -- the published baseline is now " @@ -621,6 +752,7 @@ void Tick() { // the host, which arbitrates and relays. Both sweep their parked inbound blobs -- the host's // were never swept at all, so a client write it could not resolve on arrival was neither // retried nor evicted for the life of the session. + SweepBirths(now); DrainDirty(s); pk::Sweep(&ReplayParked, NowMs()); } @@ -652,6 +784,47 @@ void OnContentsChunk(const coop::net::BlobChunkPayload& p, uint8_t senderSlot) { } } +void NoteAuthoredBirth(void* actor) { + if (IsHost() || !IsContainerActor(actor)) return; + void* inv = InventoryOf(actor); + if (!inv || !IsWorldContainerInventory(inv)) return; + if (g_authoredBirths.size() >= kMaxBirths) g_authoredBirths.erase(g_authoredBirths.begin()); + g_authoredBirths.push_back(Birth{actor, R::InternalIndexOf(actor), 0, NowMs() + kBirthTtlMs}); + UE_LOGI("container_contents: CLIENT threw container %p -- its contents go to the host once bound", actor); +} + +void ExpectBirthSlice(void* actor, uint8_t authorSlot) { + if (!IsHost() || !IsContainerActor(actor)) return; + if (g_awaitedBirths.size() >= kMaxBirths) g_awaitedBirths.erase(g_awaitedBirths.begin()); + g_awaitedBirths.push_back(Birth{actor, R::InternalIndexOf(actor), authorSlot, NowMs() + kBirthTtlMs}); +} + +void NoteHostBirth(void* actor) { + if (!IsHost() || !IsContainerActor(actor)) return; + for (const Birth& b : g_awaitedBirths) + if (b.actor == actor) return; // built from a client's intent: its author holds the contents + void* inv = InventoryOf(actor); + if (!inv || !IsWorldContainerInventory(inv)) return; + const auto eid = coop::element::Registry::Get().EidForActor(actor); + if (eid == coop::element::kInvalidId) return; + std::vector recs; + const bool readable = ReadContents(inv, recs); + if (readable && recs.empty()) return; // the mirrors are born empty too + // Not readable yet is not empty: the publication is owed and retried until it is. + if (!readable) g_owedFirst[static_cast(eid)] = NowMs() + kBirthTtlMs; + g_dirty.insert(static_cast(eid)); +} + +void OnPeerGone(uint8_t slot) { + // A transfer is bound to the peer that threw: a later occupant of the slot does not complete it. + for (size_t i = 0; i < g_awaitedBirths.size();) { + if (g_awaitedBirths[i].authorSlot != slot) { ++i; continue; } + UE_LOGW("container_contents: slot %u left before the contents of a container it threw came", + static_cast(slot)); + g_awaitedBirths.erase(g_awaitedBirths.begin() + static_cast(i)); + } +} + void QueueConnectBroadcastForSlot(int peerSlot) { auto* s = g_session.load(std::memory_order_acquire); if (!s || !IsHost()) return; @@ -668,6 +841,8 @@ void QueueConnectBroadcastForSlot(int peerSlot) { if (!ue_wrap::prop::WalksToBase(R::ClassOf(pr.actor), base)) continue; void* inv = InventoryOf(pr.actor); if (!inv || !IsWorldContainerInventory(inv)) continue; // BOUNDARY 1 (fail-closed) + // A transfer in progress reaches the joiner with the fan-out that completes it. + if (AwaitedBirth(static_cast(pr.id), 0)) continue; if (BroadcastContainer(s, static_cast(pr.id), inv, peerSlot, /*force=*/true)) ++sent; } UE_LOGI("container_contents: connect seed -> slot %d: %zu world containers", peerSlot, sent); @@ -718,6 +893,10 @@ void OnDisconnect() { g_sentHash.clear(); g_baseHash.clear(); g_appliedHash.clear(); + g_authoredBirths.clear(); + g_birthEids.clear(); + g_awaitedBirths.clear(); + g_owedFirst.clear(); g_asm.Clear(); g_nextSweep = 0; g_announced = false; diff --git a/src/votv-coop/src/coop/props/host_spawn_watcher.cpp b/src/votv-coop/src/coop/props/host_spawn_watcher.cpp index 73f4ce534..9447a7dfb 100644 --- a/src/votv-coop/src/coop/props/host_spawn_watcher.cpp +++ b/src/votv-coop/src/coop/props/host_spawn_watcher.cpp @@ -11,6 +11,7 @@ #include "coop/props/prop_element_tracker.h" #include "coop/props/prop_lifecycle.h" // ExpressSpawnedProp (reuse the keyed broadcast) #include "coop/props/prop_save_data.h" // PublishHostBirth (the record behind the express) +#include "coop/props/container_contents_sync.h" // NoteHostBirth (a container's contents behind it) #include "coop/props/prop_drive_host.h" // Coast (a falling birth's pose until it rests) #include "coop/props/prop_wire_parity.h" // PhysFlagsOf (frozen, static and sleep) #include "coop/props/remote_prop_spawn.h" @@ -412,6 +413,7 @@ void DrainPendingSpawns(coop::net::Session* s) { // Tracked since the enqueue (the init seam expressed it first): someone owns its row, and // only its fall is left to give a channel. if (coop::element::Registry::Get().EidForActor(e.actor) != coop::element::kInvalidId) { + coop::props::container_contents_sync::NoteHostBirth(e.actor); CoastIfFalling(e.actor); continue; } @@ -431,6 +433,8 @@ void DrainPendingSpawns(coop::net::Session* s) { // it every client got the class default -- #39's report, run 1: nine of nine discs the // host ejected read EMPTIED on the client and INTACT on the host. coop::prop_save_data::PublishHostBirth(s, e.actor); + // A container's contents are not in that record: its own lane publishes them. + coop::props::container_contents_sync::NoteHostBirth(e.actor); CoastIfFalling(e.actor); } } diff --git a/src/votv-coop/src/coop/props/prop_drop_intent.cpp b/src/votv-coop/src/coop/props/prop_drop_intent.cpp index 4f7f51ab4..c5f19ba4d 100644 --- a/src/votv-coop/src/coop/props/prop_drop_intent.cpp +++ b/src/votv-coop/src/coop/props/prop_drop_intent.cpp @@ -14,7 +14,7 @@ #include "coop/props/prop_spawn_authoring.h" // BirthIsPlayerAuthored (the spawn menu / the toolgun) #include "coop/props/prop_wire_parity.h" // PhysFlagsOf, the one builder of a live prop's flags #include "coop/props/prop_element_tracker.h"// GetPropElementIdForActor, ResolveLiveActorByKey -#include "coop/props/container_contents_sync.h" // TakeObjInFlight -- mark a container-extraction birth +#include "coop/props/container_contents_sync.h" // TakeObjInFlight; a thrown container's birth slice #include "coop/session/world_load_episode.h" // InEpisode (quiet during the join loadObjects churn) #include "ue_wrap/core/call.h" // ParamFrame + Call (setKey on the host re-spawn) #include "ue_wrap/engine/engine.h" // BeginDeferredSpawn/FinishDeferredSpawn/SetActorScale3D @@ -318,6 +318,8 @@ void* HostSpawnPlacedProp(const coop::net::PropDropIntentPayload& p, const std:: // machine. if (!coop::prop_save_data::ApplyParked(actor, key)) coop::prop_save_data::ExpectRecordFor(key, authorSlot); + // A container's contents are not in its record: they come as the author's birth slice. + coop::props::container_contents_sync::ExpectBirthSlice(actor, authorSlot); return actor; } @@ -491,6 +493,8 @@ void Tick(coop::net::Session* session) { // this prop from the intent and would otherwise author a class-default copy -- a blank // tape, an empty disc -- and broadcast that as truth. coop::prop_save_data::Publish(session, e.actor, key); + // And a container's contents, which its record does not carry, once the host's echo binds it. + coop::props::container_contents_sync::NoteAuthoredBirth(e.actor); if (parked) UnparkKey(key); // consume from BOTH the set AND the FIFO (mirror invariant) // The claim, for every entry that was not claimed at the seam: this prop is now named on // the wire, so the join sweep must not doom it while the host's answer is in flight. A diff --git a/src/votv-coop/src/coop/session/subsystems.cpp b/src/votv-coop/src/coop/session/subsystems.cpp index f933caad9..0737d8121 100644 --- a/src/votv-coop/src/coop/session/subsystems.cpp +++ b/src/votv-coop/src/coop/session/subsystems.cpp @@ -431,6 +431,7 @@ void DisconnectSlot(coop::net::Session& session, int slot) { coop::dish_hashcode_sync::OnPeerGone(static_cast(slot)); coop::sat_console_sync::OnPeerGone(static_cast(slot)); // its terminal kept, running its command, for its return coop::daily_task_sync::OnPeerGone(static_cast(slot)); // a departed slot is owed no task + coop::props::container_contents_sync::OnPeerGone(static_cast(slot)); // a container transfer it had in flight coop::signal_sync::OnDisconnectSlot(slot); coop::email_sync::OnDisconnectSlot(slot); // Shut the chat lane's per-slot seed gate: the next occupant's applied range starts empty, so From 1cbc2d4546e9a77bf588d2034d99db3ce8e0b430 Mon Sep 17 00:00:00 2001 From: pelmentor Date: Sat, 10 Oct 2026 01:57:07 +0600 Subject: [PATCH 035/306] [coop] a client birth's record is published by its land The host's await for a client birth now ends at the apply, so a peer that became ready inside it was declined by its join seed and then got nothing: the record went out at arrival, before it was ready. The land now publishes what the host holds, and the arrival republishes only a claim nobody awaits. A class this lane does not carry is never owed a record, so it is no longer awaited for 15 s and then reported as a record that never landed. Co-Authored-By: Claude Opus 5.5 --- .../include/coop/props/prop_save_data.h | 9 ++--- .../src/coop/props/prop_drop_intent.cpp | 6 ++-- .../src/coop/props/prop_save_data.cpp | 34 +++++++++++-------- 3 files changed, 28 insertions(+), 21 deletions(-) diff --git a/src/votv-coop/include/coop/props/prop_save_data.h b/src/votv-coop/include/coop/props/prop_save_data.h index b1d1610bb..df69a4fd9 100644 --- a/src/votv-coop/include/coop/props/prop_save_data.h +++ b/src/votv-coop/include/coop/props/prop_save_data.h @@ -72,8 +72,9 @@ bool PublishWithSpawn(coop::net::Session* s, void* actor, const coop::net::WireK // record that arrives first is parked, and the spawn drain publishes in that gap); when that peer // goes; at teardown; or on a timeout -- an intent can be lost to a refused rate window or a // malformed body, and a key nobody clears would silence this lane's publisher for that prop for the -// whole session, the join seed included. -void ExpectRecordFor(const std::wstring& key, uint8_t senderSlot); +// whole session, the join seed included. Its land publishes the record. A class this lane does not +// carry (`Covers`) is never owed one, so `actor` of such a class is not awaited at all. +void ExpectRecordFor(void* actor, const std::wstring& key, uint8_t senderSlot); // A chunk of either kind off the wire. `intent` distinguishes PropSaveDataIntent (a client's // claim, which only the host acts on) from PropSaveData (the canonical). The session is the one @@ -84,8 +85,8 @@ void OnChunk(coop::net::Session& s, const coop::net::BlobChunkPayload& p, uint8_ // Apply a parked record to `actor`, whose Key is now readable; called at every mirror-birth seam. // Unbudgeted, unlike the park drain: a newborn mirror must be right at birth, and the birth rate is // already paced by whatever seam produced it. -// A record for a prop this peer has not created yet is parked BY KEY, with no expiry and no retry -// count: an element id names an actor, and the point of the lane is that the actor is destroyed +// A record for a prop this peer has not created yet is parked BY KEY, with no expiry (its apply is +// retried a few times, then left parked): an element id names an actor, and the point of the lane is that the actor is destroyed // and remade, while a Key survives that. The park is capped by count and an eviction is loud, // because a bound on memory is not a deadline on an identity. bool ApplyParked(void* actor, const std::wstring& key); diff --git a/src/votv-coop/src/coop/props/prop_drop_intent.cpp b/src/votv-coop/src/coop/props/prop_drop_intent.cpp index c5f19ba4d..8ad0bc4e9 100644 --- a/src/votv-coop/src/coop/props/prop_drop_intent.cpp +++ b/src/votv-coop/src/coop/props/prop_drop_intent.cpp @@ -311,13 +311,13 @@ void* HostSpawnPlacedProp(const coop::net::PropDropIntentPayload& p, const std:: E::SetActorScale3D(actor, ue_wrap::FVector{clamp(p.scaleX), clamp(p.scaleY), clamp(p.scaleZ)}); } // The prop's own save record, if the author's copy is already here. It usually is not -- it - // rides behind this intent in the same FIFO -- and then it lands on this actor by Key the - // moment it arrives, which is what makes the store keyed by identity rather than by actor. + // rides behind this intent in the same FIFO -- and then it lands on this actor by Key once the + // actor is indexed, which is what makes the store keyed by identity rather than by actor. // Until then this key is AWAITED: the host has just spawned a class-default copy, and // publishing that as canonical would overwrite the author's real state on the author's own // machine. if (!coop::prop_save_data::ApplyParked(actor, key)) - coop::prop_save_data::ExpectRecordFor(key, authorSlot); + coop::prop_save_data::ExpectRecordFor(actor, key, authorSlot); // A container's contents are not in its record: they come as the author's birth slice. coop::props::container_contents_sync::ExpectBirthSlice(actor, authorSlot); return actor; diff --git a/src/votv-coop/src/coop/props/prop_save_data.cpp b/src/votv-coop/src/coop/props/prop_save_data.cpp index 28101c72d..4ed2efb86 100644 --- a/src/votv-coop/src/coop/props/prop_save_data.cpp +++ b/src/votv-coop/src/coop/props/prop_save_data.cpp @@ -301,10 +301,11 @@ bool TryApply(void* actor, const std::wstring& key, const SR::SaveRecord& rec) { if (!SR::ApplyRecord(actor, rec)) return false; ++g_appliedTotal; coop::dev::food_clock_probe::NoteApply(actor, key, rec); - // The author's record has LANDED, so the host may publish this key again. Not at its arrival: - // a record that arrives before the host's actor is indexed is parked, and the spawn drain runs - // in that gap and would publish the class default the host just spawned. - g_awaiting.erase(key); + // The author's record has LANDED, so the host may publish this key again, and does so here, from + // what it now holds: a peer that became ready while the key was awaited was declined by its join + // seed then. Not at its arrival: a record that arrives before the host's actor is indexed is + // parked, and the spawn drain runs in that gap and would publish the class default it spawned. + if (g_awaiting.erase(key) && g_session) Publish(g_session, actor, key); return true; } @@ -369,8 +370,8 @@ bool SendBody(coop::net::Session* s, int peerSlot, const std::wstring& key, } // namespace -void ExpectRecordFor(const std::wstring& key, uint8_t senderSlot) { - if (key.empty()) return; +void ExpectRecordFor(void* actor, const std::wstring& key, uint8_t senderSlot) { + if (key.empty() || !Covers(actor)) return; // no record of its own here: none is owed if (g_awaiting.size() >= kMaxAwaiting && !g_awaiting.count(key)) { // OLDEST, not lexicographically first: evicting by key order drops a wait minted seconds // ago and keeps a stale one, which is the opposite of what a cap is for. @@ -542,9 +543,11 @@ void OnChunk(coop::net::Session& s, const coop::net::BlobChunkPayload& p, uint8_ LandRecord(key, std::move(rec), senderSlot, body.size(), /*budgeted=*/true); return; } - // The host takes a client's claim: apply or park it locally, then re-publish the result as the - // canonical so every peer -- the author included, as the acknowledgement -- takes the same - // bytes from the same authority. + // The host takes a client's claim: apply or park it locally, then re-publish it as the canonical + // so every peer -- the author included, as the acknowledgement -- takes the same bytes from the + // same authority. A birth's record (a prop the host spawned from this author's intent) is + // published by its land instead (TryApply), from what the host then holds. + g_session = &s; void* actor = PT::FindLiveActorByKey(key); if (actor && !Covers(actor)) { // The claim names a class another lane owns, or one that carries no save state of its own. @@ -555,14 +558,17 @@ void OnChunk(coop::net::Session& s, const coop::net::BlobChunkPayload& p, uint8_ key.c_str(), static_cast(senderSlot)); return; } + const bool birth = g_awaiting.count(key) != 0; if (LandRecord(key, SR::SaveRecord(rec), senderSlot, body.size(), /*budgeted=*/false)) { - UE_LOGI("prop_save_data: HOST took a client record (key '%ls', slot %u) -- republishing", - key.c_str(), static_cast(senderSlot)); + UE_LOGI("prop_save_data: HOST took a client record (key '%ls', slot %u) -- %s", + key.c_str(), static_cast(senderSlot), + birth ? "published from its land" : "republishing"); } else { - UE_LOGI("prop_save_data: HOST parked a client record (key '%ls', slot %u) -- " - "republishing", key.c_str(), static_cast(senderSlot)); + UE_LOGI("prop_save_data: HOST parked a client record (key '%ls', slot %u) -- %s", + key.c_str(), static_cast(senderSlot), + birth ? "published when it lands" : "republishing"); } - SendBody(&s, -1, key, rec); + if (!birth) SendBody(&s, -1, key, rec); } bool ApplyParked(void* actor, const std::wstring& key) { From 5207079976cc438f42bbddff47c78c08c87afbb0 Mon Sep 17 00:00:00 2001 From: pelmentor Date: Sat, 10 Oct 2026 02:01:18 +0600 Subject: [PATCH 036/306] [ue_wrap] a world container's inventory leaves the contents lane The reflected fields that tie a container to its propInventory, the slot of the shared GObjStack its contents live in, the record read, the nested index rule and the game's re-derive verbs are engine facts, so they move to ue_wrap/actors/container_inventory. The lane keeps its wire boundaries and the slice cap. A move: nothing changes behaviour. It also takes the lane from 906 lines back under the 800 cap the thrown-container transfer broke. Co-Authored-By: Claude Opus 5.5 --- .../ue_wrap/actors/container_inventory.h | 62 +++++ src/votv-coop/sources.cmake | 1 + .../coop/props/container_contents_sync.cpp | 253 ++++-------------- .../ue_wrap/actors/container_inventory.cpp | 156 +++++++++++ 4 files changed, 271 insertions(+), 201 deletions(-) create mode 100644 src/votv-coop/include/ue_wrap/actors/container_inventory.h create mode 100644 src/votv-coop/src/ue_wrap/actors/container_inventory.cpp diff --git a/src/votv-coop/include/ue_wrap/actors/container_inventory.h b/src/votv-coop/include/ue_wrap/actors/container_inventory.h new file mode 100644 index 000000000..7c92208e8 --- /dev/null +++ b/src/votv-coop/include/ue_wrap/actors/container_inventory.h @@ -0,0 +1,62 @@ +// ue_wrap/actors/container_inventory.h -- a world container (prop_container_C) and the propInventory_C +// component that holds its contents: the reflected fields that tie the two together, the slot of the +// save slot's shared GObjStack the contents live in, and the game's own verbs that re-derive what the +// container shows from them. Engine-wrapper layer (principle 7): no wire and no arbitration, which +// coop/props/container_contents_sync owns. Every reflected offset is resolved once and cached; -1 means +// looked and failed, never retried, never guessed. Game thread. +#pragma once + +#include "ue_wrap/actors/save_record.h" + +#include +#include +#include + +namespace ue_wrap::container_inventory { + +// Whether `actor` is a container, and `obj` a propInventory component. A class not loaded yet, or +// still loading, answers false and is asked for again at the next call. +bool IsContainer(void* actor); +bool IsInventory(void* obj); + +// The propInventory component of a container actor, live, or null. +void* InventoryOf(void* container); +// The container that owns a propInventory component, live, or null. +void* OwnerOf(void* inventory); + +// Fail closed: true only for a WORLD container's own inventory. Player true is a personal inventory +// (mainPlayer and ui_playerInventory share the same global GObjStack, and GObjStack[0] is the local +// player's inventory by construction, baked by the player container's component template); an +// unresolvable flag reads as personal too. ue_wrap::inventory::ReadLivePersonalStore asserts the same +// flag in the other direction. +bool IsWorldInventory(void* inventory); + +// The live TArray holding this inventory's contents inside saveSlot.GObjStack (the +// struct_mObject element's single field, at +0), or null when the save slot, the offsets or the index +// do not resolve. An index of -1 is a component never initialised, with nothing to read or write. +uint8_t* ContentsSlot(void* inventory); + +// The records the slot holds, read whole. False when the slot does not resolve, or when it holds more +// than `maxRecords` (nothing is read then: a cut read is a silent lie); `countOut`, when given, is the +// slot's record count, or -1 when it does not resolve. +bool ReadContents(void* inventory, std::vector& out, size_t maxRecords, + int32_t* countOut = nullptr); + +// Whether a record's class is a container. A container record's ints[0][0] is its own GObjStack index, +// which prop_container::loadData reads unguarded and propInventory::init binds when it is >= 0; -1 is +// the class default's "none", the value the init guard is written against. +bool IsContainerRecord(const save_record::SaveRecord& r); +bool CarriesInventoryIndex(const save_record::SaveRecord& r); +// Set ints[0][0] to -1, keeping every other entry. Never by clearing ints: an empty array reads as +// index 0, which the guard passes, and the container would bind GObjStack[0]. +void ClearInventoryIndex(save_record::SaveRecord& r); + +// Re-derive what the container shows -- currVol, Mass, the display names -- through the game's own +// verbs (updateVolumesAndMass on the container, recalculateNames on the inventory), never by raw +// writes. Either argument may be null. A verb that does not resolve is said once. +void RederiveShownState(void* container, void* inventory); + +// The inventory's currVol. False when the field does not resolve. +bool CurrentVolume(void* inventory, float& out); + +} // namespace ue_wrap::container_inventory diff --git a/src/votv-coop/sources.cmake b/src/votv-coop/sources.cmake index 4e3bbcf29..faecbc858 100644 --- a/src/votv-coop/sources.cmake +++ b/src/votv-coop/sources.cmake @@ -44,6 +44,7 @@ set(VOTVCOOP_SOURCES src/ue_wrap/engine/engine_save.cpp src/ue_wrap/engine/data_table.cpp src/ue_wrap/actors/begin_equipment.cpp + src/ue_wrap/actors/container_inventory.cpp src/ue_wrap/actors/container_openers.cpp src/ue_wrap/actors/container_view.cpp src/ue_wrap/actors/floppy_disc.cpp diff --git a/src/votv-coop/src/coop/props/container_contents_sync.cpp b/src/votv-coop/src/coop/props/container_contents_sync.cpp index 73ae8132d..2449c4752 100644 --- a/src/votv-coop/src/coop/props/container_contents_sync.cpp +++ b/src/votv-coop/src/coop/props/container_contents_sync.cpp @@ -15,19 +15,15 @@ #include "coop/net/blob_chunks.h" #include "coop/net/session.h" #include "coop/session/net_pump.h" -#include "ue_wrap/actors/inventory.h" // ResolveSaveSlot -#include "ue_wrap/actors/prop.h" // WalksToBase +#include "ue_wrap/actors/container_inventory.h" #include "ue_wrap/actors/save_record.h" -#include "ue_wrap/core/component_calls.h" // CallParamless #include "ue_wrap/core/log.h" -#include "ue_wrap/core/object_index.h" #include "ue_wrap/core/reflection.h" #include "ue_wrap/core/script_gate.h" #include #include #include -#include #include #include #include @@ -43,6 +39,7 @@ namespace sg = ue_wrap::script_gate; namespace wp = coop::props::container_write_policy; namespace pk = coop::props::container_park; namespace cw = coop::props::container_slice_wire; +namespace ci = ue_wrap::container_inventory; using coop::element::LivePropActor; @@ -126,138 +123,22 @@ bool IsHost() { return s && s->role() == coop::net::Role::Host; } -// Reflected offsets, resolved once and cached. - -int32_t g_offInvIndex = -2; // propInventory_C.Index -int32_t g_offInvPlayer = -2; // propInventory_C.Player -- the world-vs-PERSONAL discriminator -int32_t g_offInvOwner = -2; // propInventory_C.Owner -- the Aprop_container_C -int32_t g_offGObjStack = -2; // saveSlot_C.GObjStack -int32_t g_offPropInv = -2; // prop_container_C.propInventory - -// An offset resolved once; -1 means looked and failed, never retried, never guessed. -int32_t CachedOffset(int32_t& slot, void* cls, const wchar_t* name) { - if (slot == -2) { - slot = cls ? R::FindPropertyOffset(cls, name) : -1; - if (slot < 0) - UE_LOGW("container_contents: could not resolve %ls -- lane inert for it", name); - } - return slot; -} - -template T ReadAt(const void* base, int32_t off) { - T v{}; - std::memcpy(&v, reinterpret_cast(base) + off, sizeof(T)); - return v; -} - -// The propInventory component of a container actor, or null. -void* InventoryOf(void* containerActor) { - if (!containerActor) return nullptr; - if (CachedOffset(g_offPropInv, R::ClassOf(containerActor), L"propInventory") < 0) return nullptr; - void* inv = ReadAt(containerActor, g_offPropInv); - return (inv && R::IsLive(inv)) ? inv : nullptr; -} - -// The owning Aprop_container_C of a propInventory component, or null. -void* OwnerOf(void* inv) { - if (!inv) return nullptr; - if (CachedOffset(g_offInvOwner, R::ClassOf(inv), L"Owner") < 0) return nullptr; - void* owner = ReadAt(inv, g_offInvOwner); - return (owner && R::IsLive(owner)) ? owner : nullptr; -} - -// Boundary 1, fail closed: true only for a world container this lane may author. Player true is a -// personal inventory (mainPlayer and ui_playerInventory share the same global GObjStack, and -// GObjStack[0] is the local player's inventory by construction, baked by the player container's -// component template), and authoring it from the host would wipe that peer's inventory; an -// unresolvable offset refuses too. The same flag is the address assertion in -// ue_wrap::inventory::ReadLivePersonalStore, fail-closed in the other direction. GObjStackSlot and -// that reader each resolve GObjStack and Index themselves; folding them is a refactor of a shipped -// lane for its own arc. -bool IsWorldContainerInventory(void* inv) { - if (!inv) return false; - if (CachedOffset(g_offInvPlayer, R::ClassOf(inv), L"Player") < 0) return false; - return ReadAt(inv, g_offInvPlayer) == 0; -} - -// The live TArray slot for this component's contents inside the global GObjStack; -// null if the save slot, the offsets or the index do not resolve. The contents array is the -// struct_mObject element's single field, at +0. -uint8_t* GObjStackSlot(void* inv) { - if (!inv) return nullptr; - void* save = ue_wrap::inventory::ResolveSaveSlot(); - if (!save) return nullptr; - if (CachedOffset(g_offGObjStack, R::ClassOf(save), L"GObjStack") < 0) return nullptr; - if (CachedOffset(g_offInvIndex, R::ClassOf(inv), L"Index") < 0) return nullptr; - const int32_t idx = ReadAt(inv, g_offInvIndex); - if (idx < 0) return nullptr; // -1 = never initialised; nothing to ship or apply - const SR::Arr stack = SR::ReadArr(save, g_offGObjStack); - if (idx >= stack.num) return nullptr; - return const_cast(stack.data) + static_cast(idx) * SR::kMxStride; -} - -// The container base and the inventory component's class, looked up per use, one index lookup each: a -// class not loaded yet, or still loading, answers null and is asked for again. Game thread, as every -// caller is. -void* ContainerClass() { return ue_wrap::object_index::ClassByName(L"prop_container_C"); } -void* InventoryClass() { return ue_wrap::object_index::ClassByName(L"propInventory_C"); } - -// A record whose class is a container: a class not loaded yet is not one now, and is asked for again at -// the next record. -bool RecordIsNestedContainer(const SR::SaveRecord& r) { - if (r.className.empty()) return false; - void* const base = ContainerClass(); - if (!base) return false; - void* const cls = ue_wrap::object_index::ClassByName(r.className.c_str()); - return cls && ue_wrap::prop::WalksToBase(cls, base); -} - -// Is this a propInventory_C component, and is that a container actor: the verb filter matches on -// the verb name alone, so any class with an addObject would arrive, and the apply side must not -// read a cached component offset off an eid that resolved to something else. -bool IsInventoryComponent(void* obj) { - void* base = InventoryClass(); - return base && obj && ue_wrap::prop::WalksToBase(R::ClassOf(obj), base); -} -bool IsContainerActor(void* actor) { - void* base = ContainerClass(); - return base && actor && ue_wrap::prop::WalksToBase(R::ClassOf(actor), base); -} - -// Boundary 2: a nested container's ints[0][0] is its own GObjStack index, a slot number in the -// sender's array that must not survive the wire. Clearing ints[] is wrong: prop_container::loadData -// reads ints[0][0] unguarded, Array_Get zero-fills an out-of-range read, so an empty array yields -// index 0, which propInventory::init's `index >= 0` guard passes, and the container reuses -// GObjStack[0], a slot owned by someone else. The sentinel -1 is what the guard is written against -// (the CDO's default); every other entry is preserved. -void NeuterNestedIndex(SR::SaveRecord& r) { - if (r.ints.empty()) r.ints.resize(1); - if (r.ints[0].empty()) r.ints[0].resize(1); - r.ints[0][0] = -1; -} - -// Does this record still carry a slot number from wherever it came from? -bool CarriesForeignIndex(const SR::SaveRecord& r) { - return !r.ints.empty() && !r.ints[0].empty() && r.ints[0][0] != -1; -} - +// BOUNDARY 1, fail closed: only a world container's own inventory is this lane's to author +// (ci::IsWorldInventory); authoring a personal inventory from the host would wipe that peer's own. +// BOUNDARY 2: a nested container's ints[0][0] is its own GObjStack index, a slot number in the +// sender's array that must not survive the wire, so it is shipped and written as -1 +// (ci::ClearInventoryIndex says why never by clearing ints). bool ReadContents(void* inv, std::vector& out, bool neuterNested = true) { - uint8_t* slot = GObjStackSlot(inv); - if (!slot) return false; - const SR::Arr objs = SR::ReadArr(slot, 0); // struct_mObject.obj @ +0 - if (static_cast(objs.num) > cw::kMaxRecords) { - UE_LOGW("container_contents: %d records exceeds the %zu cap -- refusing to ship a " - "truncated slice", objs.num, cw::kMaxRecords); + int32_t count = -1; + if (!ci::ReadContents(inv, out, cw::kMaxRecords, &count)) { + if (count > static_cast(cw::kMaxRecords)) + UE_LOGW("container_contents: %d records exceeds the %zu cap -- refusing to ship a " + "truncated slice", count, cw::kMaxRecords); return false; } - out.clear(); - out.reserve(static_cast(objs.num)); - for (int32_t i = 0; i < objs.num; ++i) { - SR::SaveRecord r; - SR::ReadSaveRecord(objs.data + static_cast(i) * SR::kSaveStride, r); - if (neuterNested && RecordIsNestedContainer(r)) NeuterNestedIndex(r); - out.push_back(std::move(r)); - } + if (neuterNested) + for (auto& r : out) + if (ci::IsContainerRecord(r)) ci::ClearInventoryIndex(r); return true; } @@ -265,8 +146,6 @@ bool ReadContents(void* inv, std::vector& out, bool neuterNested // False if the send was refused (the caller arms the retry). Run by both peers: on the host toSlot // < 0 fans out; on a client the same call reaches the host alone, the author-to-arbiter edge. -// RederiveManagedState is defined below. -void RederiveManagedState(void* owner, void* inv); bool BroadcastContainer(coop::net::Session* s, uint32_t eid, void* inv, int toSlot, bool force) { std::vector recs; @@ -328,7 +207,7 @@ bool BroadcastContainer(coop::net::Session* s, uint32_t eid, void* inv, int toSl // peer converged. A targeted send -- a joiner's seed, the truth re-sent to a refused // author -- changed nothing on this peer, so it re-derives nothing; the seed sends every // container in the world in one frame. - if (toSlot < 0) RederiveManagedState(OwnerOf(inv), inv); + if (toSlot < 0) ci::RederiveShownState(ci::OwnerOf(inv), inv); UE_LOGI("container_contents: eid=%u shipped %zu records (%zu B)%s%s%s", eid, recs.size(), blob.size(), toSlot < 0 ? "" : " [targeted]", @@ -426,9 +305,9 @@ void DrainDirty(coop::net::Session* s) { // Resolved forward from the eid every sweep: a container destroyed since the edge stops // resolving. void* actor = LivePropActor(eid); - if (!actor || !IsContainerActor(actor)) continue; - void* inv = InventoryOf(actor); - if (!inv || !IsWorldContainerInventory(inv)) { // BOUNDARY 1 (fail-closed) + if (!actor || !ci::IsContainer(actor)) continue; + void* inv = ci::InventoryOf(actor); + if (!inv || !ci::IsWorldInventory(inv)) { // BOUNDARY 1 (fail-closed) // A first publication owed for it waits for the component instead of ending here. auto owed = g_owedFirst.find(eid); if (owed != g_owedFirst.end() && NowMs() < owed->second) g_retry.insert(eid); @@ -444,28 +323,7 @@ void DrainDirty(coop::net::Session* s) { // The apply. // The setter-managed state (currVol, Mass, the display names) is re-derived through the engine's -// own verbs, never raw-written; updateVolumesAndMass calls only Get Volume, and the ejector -// checkObjectsVolume (which calls takeObj) is not called. Each verb is looked up on the instance's own -// class through the memoised dispatch lookup, which climbs to the class that declares it -- -// updateVolumesAndMass is declared only on Aprop_container_C, and every real container is a subclass -- -// and holds its answer by the class's slot and serial, so no function of a class that is gone is -// called. A verb that does not resolve is said once: the applied contents then show a stale currVol or -// stale names. -void RederiveManagedState(void* owner, void* inv) { - void* const updateVol = - owner ? R::FindDispatchFunctionCached(R::ClassOf(owner), L"updateVolumesAndMass") : nullptr; - void* const recalcNames = inv ? R::FindDispatchFunctionCached(R::ClassOf(inv), L"recalculateNames") : nullptr; - if ((owner && !updateVol) || (inv && !recalcNames)) { - static bool s_said = false; - if (!s_said) { - s_said = true; - UE_LOGW("container_contents: re-derive verb MISSING (updateVolumesAndMass=%p recalculateNames=%p) " - "-- applied contents will show a STALE currVol / names", updateVol, recalcNames); - } - } - if (updateVol) ue_wrap::component_calls::CallParamless(owner, updateVol); - if (recalcNames) ue_wrap::component_calls::CallParamless(inv, recalcNames); -} +// own verbs, never raw-written (ci::RederiveShownState). // What an inbound blob did to this peer; a bool cannot express it, since "handled" and "changed // something" are different facts and the relay needs the second. Gated on a bool that was true @@ -483,23 +341,23 @@ Ingest ApplyContents(uint32_t eid, const std::vector& recs, uint void* actor = LivePropActor(eid); if (!actor) return Ingest::Park; // The wire eid must name a container before a cached component offset is read off it. - if (!IsContainerActor(actor)) { + if (!ci::IsContainer(actor)) { UE_LOGW("container_contents: eid=%u does not resolve to a container -- refusing", eid); return Ingest::Handled; } - void* inv = InventoryOf(actor); - if (!inv || !IsInventoryComponent(inv)) return Ingest::Park; + void* inv = ci::InventoryOf(actor); + if (!inv || !ci::IsInventory(inv)) return Ingest::Park; { // Identical contents -> do nothing. The raw-write orphans the previous arrays, so a // no-op apply must not allocate at all (that is what bounds the leak). auto it = g_appliedHash.find(eid); if (it != g_appliedHash.end() && it->second == blobHash) return Ingest::Handled; } - if (!IsWorldContainerInventory(inv)) { // BOUNDARY 1 (fail-closed) + if (!ci::IsWorldInventory(inv)) { // BOUNDARY 1 (fail-closed) UE_LOGW("container_contents: eid=%u resolves to a PERSONAL inventory (or an unresolvable " "Player flag) -- refusing to apply", eid); return Ingest::Handled; // resolved; deliberately not applied } - uint8_t* slot = GObjStackSlot(inv); + uint8_t* slot = ci::ContentsSlot(inv); if (!slot) return Ingest::Park; // The allocator pre-flight: without it an empty array would silently replace real contents. @@ -529,7 +387,7 @@ Ingest ApplyContents(uint32_t eid, const std::vector& recs, uint g_appliedHash[eid] = blobHash; // The base a later local edit declares; never cleared by our own verb edge. if (!IsHost()) g_baseHash[eid] = blobHash; - RederiveManagedState(OwnerOf(inv), inv); + ci::RederiveShownState(ci::OwnerOf(inv), inv); UE_LOGI("container_contents: eid=%u applied %d records", eid, n); return Ingest::Applied; } @@ -562,8 +420,8 @@ Ingest ParseAndApply(const std::vector& blob, uint32_t& outEid, uint8_t // the host spend more the faster it is pushed. No peer of this build can reach that // bound (one slice per container per 250 ms sweep, and only when it changed). void* actor = LivePropActor(outEid); - void* inv = actor && IsContainerActor(actor) ? InventoryOf(actor) : nullptr; - if (s && inv && d != wp::Decision::TooFast && IsWorldContainerInventory(inv)) { + void* inv = actor && ci::IsContainer(actor) ? ci::InventoryOf(actor) : nullptr; + if (s && inv && d != wp::Decision::TooFast && ci::IsWorldInventory(inv)) { BroadcastContainer(s, outEid, inv, static_cast(senderSlot), /*force=*/true); } // Handled, not Applied: never relayed; third peers run no arbitration. @@ -583,9 +441,9 @@ Ingest ParseAndApply(const std::vector& blob, uint32_t& outEid, uint8_t // another container's contents, or a player's inventory. size_t foreignIndices = 0; for (auto& r : recs) { - if (!RecordIsNestedContainer(r)) continue; - if (CarriesForeignIndex(r)) ++foreignIndices; - NeuterNestedIndex(r); + if (!ci::IsContainerRecord(r)) continue; + if (ci::CarriesInventoryIndex(r)) ++foreignIndices; + ci::ClearInventoryIndex(r); } if (foreignIndices) { // A hit is a peer that is not this build, or a second producer that skipped the boundary. @@ -598,7 +456,7 @@ Ingest ParseAndApply(const std::vector& blob, uint32_t& outEid, uint8_t bool merged = false; if (birth) { void* actor = LivePropActor(outEid); - void* inv = actor && IsContainerActor(actor) ? InventoryOf(actor) : nullptr; + void* inv = actor && ci::IsContainer(actor) ? ci::InventoryOf(actor) : nullptr; std::vector hostRecs; if (inv && ReadContents(inv, hostRecs, /*neuterNested=*/false) && !hostRecs.empty()) { if (recs.size() + hostRecs.size() > cw::kMaxRecords) { @@ -678,10 +536,10 @@ sg::Verdict OnVerbEntry(const sg::Call& br) { // The verb filter matches on the name alone, so the context is discriminated here: the first // non-propInventory context carrying an addObject would otherwise poison the offset cache for // the session. - if (!IsInventoryComponent(br.object)) return sg::Verdict::Run; + if (!ci::IsInventory(br.object)) return sg::Verdict::Run; // A takeObj on any inventory component arms the extraction latch; addObject must not. if (br.tag == kVerbTakeObj) g_takeObjInFlight.store(true, std::memory_order_relaxed); - void* owner = OwnerOf(br.object); + void* owner = ci::OwnerOf(br.object); if (!owner) return sg::Verdict::Run; const uint32_t eid = static_cast(coop::element::Registry::Get().EidForActor(owner)); @@ -785,26 +643,26 @@ void OnContentsChunk(const coop::net::BlobChunkPayload& p, uint8_t senderSlot) { } void NoteAuthoredBirth(void* actor) { - if (IsHost() || !IsContainerActor(actor)) return; - void* inv = InventoryOf(actor); - if (!inv || !IsWorldContainerInventory(inv)) return; + if (IsHost() || !ci::IsContainer(actor)) return; + void* inv = ci::InventoryOf(actor); + if (!inv || !ci::IsWorldInventory(inv)) return; if (g_authoredBirths.size() >= kMaxBirths) g_authoredBirths.erase(g_authoredBirths.begin()); g_authoredBirths.push_back(Birth{actor, R::InternalIndexOf(actor), 0, NowMs() + kBirthTtlMs}); UE_LOGI("container_contents: CLIENT threw container %p -- its contents go to the host once bound", actor); } void ExpectBirthSlice(void* actor, uint8_t authorSlot) { - if (!IsHost() || !IsContainerActor(actor)) return; + if (!IsHost() || !ci::IsContainer(actor)) return; if (g_awaitedBirths.size() >= kMaxBirths) g_awaitedBirths.erase(g_awaitedBirths.begin()); g_awaitedBirths.push_back(Birth{actor, R::InternalIndexOf(actor), authorSlot, NowMs() + kBirthTtlMs}); } void NoteHostBirth(void* actor) { - if (!IsHost() || !IsContainerActor(actor)) return; + if (!IsHost() || !ci::IsContainer(actor)) return; for (const Birth& b : g_awaitedBirths) if (b.actor == actor) return; // built from a client's intent: its author holds the contents - void* inv = InventoryOf(actor); - if (!inv || !IsWorldContainerInventory(inv)) return; + void* inv = ci::InventoryOf(actor); + if (!inv || !ci::IsWorldInventory(inv)) return; const auto eid = coop::element::Registry::Get().EidForActor(actor); if (eid == coop::element::kInvalidId) return; std::vector recs; @@ -831,16 +689,13 @@ void QueueConnectBroadcastForSlot(int peerSlot) { std::vector pairs; coop::element::Registry::Get().SnapshotActorsByType(coop::element::ElementType::Prop, pairs); - void* base = ContainerClass(); - if (!base) return; - size_t sent = 0; for (const auto& pr : pairs) { // IsLiveByIndex: the snapshot does not protect the actor pointer. if (!pr.actor || !R::IsLiveByIndex(pr.actor, pr.internalIdx)) continue; - if (!ue_wrap::prop::WalksToBase(R::ClassOf(pr.actor), base)) continue; - void* inv = InventoryOf(pr.actor); - if (!inv || !IsWorldContainerInventory(inv)) continue; // BOUNDARY 1 (fail-closed) + if (!ci::IsContainer(pr.actor)) continue; + void* inv = ci::InventoryOf(pr.actor); + if (!inv || !ci::IsWorldInventory(inv)) continue; // BOUNDARY 1 (fail-closed) // A transfer in progress reaches the joiner with the fan-out that completes it. if (AwaitedBirth(static_cast(pr.id), 0)) continue; if (BroadcastContainer(s, static_cast(pr.id), inv, peerSlot, /*force=*/true)) ++sent; @@ -854,16 +709,14 @@ size_t SnapshotWorldContainers(WorldContainer* out, size_t want) { if (!out || want == 0) return 0; std::vector pairs; coop::element::Registry::Get().SnapshotActorsByType(coop::element::ElementType::Prop, pairs); - void* base = ContainerClass(); - if (!base) return 0; size_t n = 0; for (const auto& pr : pairs) { if (n >= want) break; // IsLiveByIndex: the snapshot does not protect the actor pointer. if (!pr.actor || !R::IsLiveByIndex(pr.actor, pr.internalIdx)) continue; - if (!ue_wrap::prop::WalksToBase(R::ClassOf(pr.actor), base)) continue; - void* inv = InventoryOf(pr.actor); - if (!inv || !IsWorldContainerInventory(inv)) continue; // BOUNDARY 1, the shipped one + if (!ci::IsContainer(pr.actor)) continue; + void* inv = ci::InventoryOf(pr.actor); + if (!inv || !ci::IsWorldInventory(inv)) continue; // BOUNDARY 1, the shipped one out[n++] = WorldContainer{static_cast(pr.id), pr.actor, inv}; } return n; @@ -875,13 +728,11 @@ bool ContentsDigest(uint32_t eid, int32_t& outCount, float& outVol) { outCount = -1; outVol = 0.f; void* actor = LivePropActor(eid); - if (!actor || !IsContainerActor(actor)) return false; - void* inv = InventoryOf(actor); - if (!inv || !IsWorldContainerInventory(inv)) return false; - if (uint8_t* slot = GObjStackSlot(inv)) outCount = SR::ReadArr(slot, 0).num; - static int32_t sOffCurrVol = -2; - if (CachedOffset(sOffCurrVol, R::ClassOf(inv), L"currVol") >= 0) - outVol = ReadAt(inv, sOffCurrVol); + if (!actor || !ci::IsContainer(actor)) return false; + void* inv = ci::InventoryOf(actor); + if (!inv || !ci::IsWorldInventory(inv)) return false; + if (uint8_t* slot = ci::ContentsSlot(inv)) outCount = SR::ReadArr(slot, 0).num; + ci::CurrentVolume(inv, outVol); return true; } diff --git a/src/votv-coop/src/ue_wrap/actors/container_inventory.cpp b/src/votv-coop/src/ue_wrap/actors/container_inventory.cpp new file mode 100644 index 000000000..fc5e0b60e --- /dev/null +++ b/src/votv-coop/src/ue_wrap/actors/container_inventory.cpp @@ -0,0 +1,156 @@ +// ue_wrap/actors/container_inventory.cpp -- see ue_wrap/actors/container_inventory.h. + +#include "ue_wrap/actors/container_inventory.h" + +#include "ue_wrap/actors/inventory.h" // ResolveSaveSlot +#include "ue_wrap/actors/prop.h" // WalksToBase +#include "ue_wrap/core/component_calls.h" // CallParamless +#include "ue_wrap/core/log.h" +#include "ue_wrap/core/object_index.h" +#include "ue_wrap/core/reflection.h" + +#include + +namespace ue_wrap::container_inventory { +namespace { + +namespace R = ue_wrap::reflection; +namespace SR = ue_wrap::save_record; + +int32_t g_offInvIndex = -2; // propInventory_C.Index +int32_t g_offInvPlayer = -2; // propInventory_C.Player -- the world-vs-PERSONAL discriminator +int32_t g_offInvOwner = -2; // propInventory_C.Owner -- the Aprop_container_C +int32_t g_offGObjStack = -2; // saveSlot_C.GObjStack +int32_t g_offPropInv = -2; // prop_container_C.propInventory +int32_t g_offCurrVol = -2; // propInventory_C.currVol + +int32_t CachedOffset(int32_t& slot, void* cls, const wchar_t* name) { + if (slot == -2) { + slot = cls ? R::FindPropertyOffset(cls, name) : -1; + if (slot < 0) + UE_LOGW("container_inventory: could not resolve %ls -- the contents lane is inert for it", name); + } + return slot; +} + +template T ReadAt(const void* base, int32_t off) { + T v{}; + std::memcpy(&v, reinterpret_cast(base) + off, sizeof(T)); + return v; +} + +// Looked up per use, one index lookup each, so a class not loaded yet is asked for again. +void* ContainerClass() { return ue_wrap::object_index::ClassByName(L"prop_container_C"); } +void* InventoryClass() { return ue_wrap::object_index::ClassByName(L"propInventory_C"); } + +} // namespace + +bool IsContainer(void* actor) { + void* base = ContainerClass(); + return base && actor && ue_wrap::prop::WalksToBase(R::ClassOf(actor), base); +} + +bool IsInventory(void* obj) { + void* base = InventoryClass(); + return base && obj && ue_wrap::prop::WalksToBase(R::ClassOf(obj), base); +} + +void* InventoryOf(void* container) { + if (!container) return nullptr; + if (CachedOffset(g_offPropInv, R::ClassOf(container), L"propInventory") < 0) return nullptr; + void* inv = ReadAt(container, g_offPropInv); + return (inv && R::IsLive(inv)) ? inv : nullptr; +} + +void* OwnerOf(void* inventory) { + if (!inventory) return nullptr; + if (CachedOffset(g_offInvOwner, R::ClassOf(inventory), L"Owner") < 0) return nullptr; + void* owner = ReadAt(inventory, g_offInvOwner); + return (owner && R::IsLive(owner)) ? owner : nullptr; +} + +bool IsWorldInventory(void* inventory) { + if (!inventory) return false; + if (CachedOffset(g_offInvPlayer, R::ClassOf(inventory), L"Player") < 0) return false; + return ReadAt(inventory, g_offInvPlayer) == 0; +} + +uint8_t* ContentsSlot(void* inventory) { + if (!inventory) return nullptr; + void* save = ue_wrap::inventory::ResolveSaveSlot(); + if (!save) return nullptr; + if (CachedOffset(g_offGObjStack, R::ClassOf(save), L"GObjStack") < 0) return nullptr; + if (CachedOffset(g_offInvIndex, R::ClassOf(inventory), L"Index") < 0) return nullptr; + const int32_t idx = ReadAt(inventory, g_offInvIndex); + if (idx < 0) return nullptr; + const SR::Arr stack = SR::ReadArr(save, g_offGObjStack); + if (idx >= stack.num) return nullptr; + return const_cast(stack.data) + static_cast(idx) * SR::kMxStride; +} + +bool ReadContents(void* inventory, std::vector& out, size_t maxRecords, + int32_t* countOut) { + if (countOut) *countOut = -1; + uint8_t* slot = ContentsSlot(inventory); + if (!slot) return false; + const SR::Arr objs = SR::ReadArr(slot, 0); // struct_mObject.obj @ +0 + if (countOut) *countOut = objs.num; + if (static_cast(objs.num) > maxRecords) return false; + out.clear(); + out.reserve(static_cast(objs.num)); + for (int32_t i = 0; i < objs.num; ++i) { + SR::SaveRecord r; + SR::ReadSaveRecord(objs.data + static_cast(i) * SR::kSaveStride, r); + out.push_back(std::move(r)); + } + return true; +} + +bool IsContainerRecord(const SR::SaveRecord& r) { + if (r.className.empty()) return false; + void* const base = ContainerClass(); + if (!base) return false; + void* const cls = ue_wrap::object_index::ClassByName(r.className.c_str()); + return cls && ue_wrap::prop::WalksToBase(cls, base); +} + +bool CarriesInventoryIndex(const SR::SaveRecord& r) { + return !r.ints.empty() && !r.ints[0].empty() && r.ints[0][0] != -1; +} + +void ClearInventoryIndex(SR::SaveRecord& r) { + if (r.ints.empty()) r.ints.resize(1); + if (r.ints[0].empty()) r.ints[0].resize(1); + r.ints[0][0] = -1; +} + +// updateVolumesAndMass calls only Get Volume; the ejector checkObjectsVolume (which calls takeObj) is +// not called. Each verb is looked up on the instance's own class through the memoised dispatch +// lookup, which climbs to the class that declares it -- updateVolumesAndMass is declared only on +// Aprop_container_C, and every real container is a subclass -- and holds its answer by the class's +// slot and serial, so no function of a class that is gone is called. +void RederiveShownState(void* container, void* inventory) { + void* const updateVol = + container ? R::FindDispatchFunctionCached(R::ClassOf(container), L"updateVolumesAndMass") : nullptr; + void* const recalcNames = + inventory ? R::FindDispatchFunctionCached(R::ClassOf(inventory), L"recalculateNames") : nullptr; + if ((container && !updateVol) || (inventory && !recalcNames)) { + static bool s_said = false; + if (!s_said) { + s_said = true; + UE_LOGW("container_inventory: re-derive verb MISSING (updateVolumesAndMass=%p recalculateNames=%p) " + "-- applied contents will show a STALE currVol / names", updateVol, recalcNames); + } + } + if (updateVol) ue_wrap::component_calls::CallParamless(container, updateVol); + if (recalcNames) ue_wrap::component_calls::CallParamless(inventory, recalcNames); +} + +bool CurrentVolume(void* inventory, float& out) { + out = 0.f; + if (!inventory || CachedOffset(g_offCurrVol, R::ClassOf(inventory), L"currVol") < 0) return false; + out = ReadAt(inventory, g_offCurrVol); + return true; +} + +} // namespace ue_wrap::container_inventory From ee7c79d65e8cb71ae4b808ce714d8e5d4d32849f Mon Sep 17 00:00:00 2001 From: pelmentor Date: Sat, 10 Oct 2026 02:04:14 +0600 Subject: [PATCH 037/306] [coop] a thrown container's transfer is held by serial and bounded The transfer held its actor as a pointer and slot index for thirty seconds; it is now a CachedObjRef. A birth slice skipped the author's arrival budget with the rest of the judgement; it spends it now. A merge past the slice cap wrote nothing and left the transfer to expire, which then published the host's copy over the thrower's: the thrower's slice is applied alone instead. A host-born container the init seam had tracked reached the spawn drain that publishes its contents only while it was simulating; it is queued whatever its physics now. A dropped throw, an evicted transfer and a gone container's debts are said or cleared rather than kept silently. Co-Authored-By: Claude Opus 5.5 --- docs/devices.md | 7 +- .../coop/props/container_write_policy.h | 14 +++- .../coop/props/container_contents_sync.cpp | 77 +++++++++++++------ .../src/coop/props/container_write_policy.cpp | 10 ++- .../src/coop/props/host_spawn_watcher.cpp | 19 +++-- 5 files changed, 89 insertions(+), 38 deletions(-) diff --git a/docs/devices.md b/docs/devices.md index d0d3f8d39..8846a8d66 100644 --- a/docs/devices.md +++ b/docs/devices.md @@ -116,9 +116,10 @@ either, until the thrower's slice lands. The thrower ships its slice once the ho container, and retries while its own slot is not readable yet; every slice of that author for the container is the newest version of the transfer, an edit made while the first one waited included, and is taken without a base. Whatever the host put in meanwhile is kept beside it, and the merged -contents go to every peer, the thrower included; if the two do not fit one container, nothing is -written and both copies keep what they hold. The wait ends after thirty seconds or when the -thrower leaves. A container the host throws publishes its contents at the express, since the +contents go to every peer, the thrower included; if the two do not fit one slice, the thrower's is +applied alone and the host's records are dropped, with a warning. A birth slice is taken unjudged +but not unbounded: it spends its author's arrival budget like an edit. The wait ends after thirty +seconds or when the thrower leaves. A container the host throws publishes its contents at the express, since the mirrors are born empty, retrying while its slot is not readable. Applying a slice raw-writes the receiver's own array slot and then re-derives everything a setter diff --git a/src/votv-coop/include/coop/props/container_write_policy.h b/src/votv-coop/include/coop/props/container_write_policy.h index cbf1d929f..9902a18c4 100644 --- a/src/votv-coop/include/coop/props/container_write_policy.h +++ b/src/votv-coop/include/coop/props/container_write_policy.h @@ -77,13 +77,19 @@ Decision Judge(const Inputs& in); // rate and the reach, and the half a selftest can drive without an engine or a session. Decision JudgeAgainstState(uint32_t eid, uint64_t baseHash, uint64_t nowMs); -// The stateful call the lane makes: the reach question first, then the base. Logs the refusal, -// including which condition failed, and counts it. An eid that resolves to no live prop element is -// NOT a reach refusal -- the lane parks such a slice and replays it, and the park is where the -// birth-skew answer lives. +// The stateful call the lane makes: the rate (SpendArrival), the reach question, then the base. Logs +// the refusal, including which condition failed, and counts it. An eid that resolves to no live prop +// element is NOT a reach refusal -- the lane parks such a slice and replays it, and the park is where +// the birth-skew answer lives. Decision Accept(uint32_t eid, uint64_t baseHash, uint8_t authorSlot, uint64_t nowMs, coop::net::Session& session, Source src); +// The rate half alone, Accept or TooFast: an arrival spends one of its author's window, a replay +// nothing. Accept runs it first; the lane runs it by itself for a slice it takes unjudged (a thrown +// container's birth), which must not be free of the bound either. +Decision SpendArrival(uint32_t eid, uint8_t authorSlot, uint64_t nowMs, coop::net::Session& session, + Source src); + // The host has told the world what this container holds, by any route -- a fan-out or a targeted // connect seed. This is the compare-and-swap baseline a later client write is judged against. void NotePublished(uint32_t eid, uint64_t contentHash); diff --git a/src/votv-coop/src/coop/props/container_contents_sync.cpp b/src/votv-coop/src/coop/props/container_contents_sync.cpp index 2449c4752..c36a3c73f 100644 --- a/src/votv-coop/src/coop/props/container_contents_sync.cpp +++ b/src/votv-coop/src/coop/props/container_contents_sync.cpp @@ -16,6 +16,7 @@ #include "coop/net/session.h" #include "coop/session/net_pump.h" #include "ue_wrap/actors/container_inventory.h" +#include "ue_wrap/core/cached_obj_ref.h" #include "ue_wrap/actors/save_record.h" #include "ue_wrap/core/log.h" #include "ue_wrap/core/reflection.h" @@ -98,9 +99,10 @@ std::atomic g_takeObjInFlight{false}; // sides expire, so a lost intent leaves nothing behind. constexpr uint64_t kBirthTtlMs = 30000; constexpr size_t kMaxBirths = 64; +// The actor is held across ticks, so it is a CachedObjRef: a successor in the same slot at the same +// address reads as dead rather than as the thrown container. struct Birth { - void* actor = nullptr; - int32_t idx = -1; + ue_wrap::CachedObjRef actor; uint8_t authorSlot = 0; // host: the author it awaits uint64_t deadlineMs = 0; }; @@ -244,8 +246,7 @@ Birth* AwaitedBirth(uint32_t eid, uint8_t authorSlot) { if (!actor) return nullptr; const uint64_t now = NowMs(); for (Birth& b : g_awaitedBirths) - if (b.actor == actor && (authorSlot == 0 || b.authorSlot == authorSlot) && now < b.deadlineMs && - R::IsLiveByIndex(b.actor, b.idx)) + if (b.actor.Is(actor) && (authorSlot == 0 || b.authorSlot == authorSlot) && now < b.deadlineMs) return &b; return nullptr; } @@ -254,22 +255,37 @@ void DropAwaitedBirth(uint32_t eid) { void* actor = LivePropActor(eid); if (!actor) return; for (size_t i = 0; i < g_awaitedBirths.size(); ++i) { - if (g_awaitedBirths[i].actor != actor) continue; + if (!g_awaitedBirths[i].actor.Is(actor)) continue; g_awaitedBirths.erase(g_awaitedBirths.begin() + static_cast(i)); return; } } +// One more birth, the oldest given up (and said) past the cap: throws are human-rate, so the cap +// only bounds a peer that floods. +void PushBirth(std::vector& births, void* actor, uint8_t authorSlot) { + if (births.size() >= kMaxBirths) { + UE_LOGW("container_contents: %zu container transfers in flight -- the oldest is given up", + births.size()); + births.erase(births.begin()); + } + Birth b; + b.actor.Set(actor); + b.authorSlot = authorSlot; + b.deadlineMs = NowMs() + kBirthTtlMs; + births.push_back(std::move(b)); +} + // Client: a thrown container the host's echo has bound is marked, its slice owed as a birth. Host: // an awaited birth that never came is dropped, and said. void SweepBirths(uint64_t now) { for (size_t i = 0; i < g_authoredBirths.size();) { const Birth& b = g_authoredBirths[i]; - if (!R::IsLiveByIndex(b.actor, b.idx) || now >= b.deadlineMs) { + if (!b.actor.Alive() || now >= b.deadlineMs) { g_authoredBirths.erase(g_authoredBirths.begin() + static_cast(i)); continue; } - const auto eid = coop::element::Registry::Get().EidForActor(b.actor); + const auto eid = coop::element::Registry::Get().EidForActor(b.actor.Get()); if (eid == coop::element::kInvalidId) { ++i; continue; } g_birthEids.insert(static_cast(eid)); g_owedFirst[static_cast(eid)] = b.deadlineMs; @@ -278,7 +294,7 @@ void SweepBirths(uint64_t now) { } for (size_t i = 0; i < g_awaitedBirths.size();) { const Birth& b = g_awaitedBirths[i]; - const bool live = R::IsLiveByIndex(b.actor, b.idx); + const bool live = b.actor.Alive(); if (live && now < b.deadlineMs) { ++i; continue; } if (live) UE_LOGW("container_contents: the contents of a container slot %u threw never came -- it " @@ -305,7 +321,12 @@ void DrainDirty(coop::net::Session* s) { // Resolved forward from the eid every sweep: a container destroyed since the edge stops // resolving. void* actor = LivePropActor(eid); - if (!actor || !ci::IsContainer(actor)) continue; + if (!actor) { // gone: nothing is owed for it any more + g_owedFirst.erase(eid); + g_birthEids.erase(eid); + continue; + } + if (!ci::IsContainer(actor)) continue; void* inv = ci::InventoryOf(actor); if (!inv || !ci::IsWorldInventory(inv)) { // BOUNDARY 1 (fail-closed) // A first publication owed for it waits for the component instead of ending here. @@ -402,6 +423,13 @@ Ingest ParseAndApply(const std::vector& blob, uint32_t& outEid, uint8_t // base it names -- an edit made while the first slice waited in the pen included. There is no // reach to measure either: the throw put it where it is. const bool birth = IsHost() && senderSlot != 0 && AwaitedBirth(outEid, senderSlot) != nullptr; + // Unjudged is not unbounded: a birth slice spends the same arrival budget an edit does, so an + // author cannot make the host parse slices for this container at any rate while it waits. + if (birth) { + auto* s = g_session.load(std::memory_order_acquire); + if (!s || wp::SpendArrival(outEid, senderSlot, NowMs(), *s, src) == wp::Decision::TooFast) + return Ingest::Handled; + } // Host arbitration before anything is touched; a refusal is answered by re-publishing the // host's truth to the author, so it converges instead of sitting on a divergent view. if (IsHost() && senderSlot != 0 && !birth) { @@ -459,15 +487,18 @@ Ingest ParseAndApply(const std::vector& blob, uint32_t& outEid, uint8_t void* inv = actor && ci::IsContainer(actor) ? ci::InventoryOf(actor) : nullptr; std::vector hostRecs; if (inv && ReadContents(inv, hostRecs, /*neuterNested=*/false) && !hostRecs.empty()) { - if (recs.size() + hostRecs.size() > cw::kMaxRecords) { - // Neither half may be dropped to make the other fit: nothing is written, and the - // transfer stays awaited, so both copies keep what they hold. + if (recs.size() + hostRecs.size() <= cw::kMaxRecords) { + recs.insert(recs.end(), hostRecs.begin(), hostRecs.end()); + merged = true; + } else { + // Past the slice cap the two cannot travel as one. The thrower's half is the only copy + // of its contents; the host's was added within the transfer's own round trip, and no + // other peer has seen it: the thrower's slice is applied alone, and every peer + // converges on it. UE_LOGW("container_contents: eid=%u -- the host's %zu record(s) do not fit beside the " - "thrower's %zu; the transfer is not applied", outEid, hostRecs.size(), recs.size()); - return Ingest::Handled; + "thrower's %zu under the %zu cap; the thrower's slice is applied alone and the " + "host's records are dropped", outEid, hostRecs.size(), recs.size(), cw::kMaxRecords); } - recs.insert(recs.end(), hostRecs.begin(), hostRecs.end()); - merged = true; } } const uint64_t contentHash = cw::ContentHash(outEid, recs); @@ -645,22 +676,24 @@ void OnContentsChunk(const coop::net::BlobChunkPayload& p, uint8_t senderSlot) { void NoteAuthoredBirth(void* actor) { if (IsHost() || !ci::IsContainer(actor)) return; void* inv = ci::InventoryOf(actor); - if (!inv || !ci::IsWorldInventory(inv)) return; - if (g_authoredBirths.size() >= kMaxBirths) g_authoredBirths.erase(g_authoredBirths.begin()); - g_authoredBirths.push_back(Birth{actor, R::InternalIndexOf(actor), 0, NowMs() + kBirthTtlMs}); + if (!inv || !ci::IsWorldInventory(inv)) { + UE_LOGW("container_contents: CLIENT threw container %p, but its inventory %s -- its contents " + "stay on this peer only", actor, inv ? "is not a world container's" : "does not resolve"); + return; + } + PushBirth(g_authoredBirths, actor, 0); UE_LOGI("container_contents: CLIENT threw container %p -- its contents go to the host once bound", actor); } void ExpectBirthSlice(void* actor, uint8_t authorSlot) { if (!IsHost() || !ci::IsContainer(actor)) return; - if (g_awaitedBirths.size() >= kMaxBirths) g_awaitedBirths.erase(g_awaitedBirths.begin()); - g_awaitedBirths.push_back(Birth{actor, R::InternalIndexOf(actor), authorSlot, NowMs() + kBirthTtlMs}); + PushBirth(g_awaitedBirths, actor, authorSlot); } void NoteHostBirth(void* actor) { if (!IsHost() || !ci::IsContainer(actor)) return; for (const Birth& b : g_awaitedBirths) - if (b.actor == actor) return; // built from a client's intent: its author holds the contents + if (b.actor.Is(actor)) return; // built from a client's intent: its author holds the contents void* inv = ci::InventoryOf(actor); if (!inv || !ci::IsWorldInventory(inv)) return; const auto eid = coop::element::Registry::Get().EidForActor(actor); diff --git a/src/votv-coop/src/coop/props/container_write_policy.cpp b/src/votv-coop/src/coop/props/container_write_policy.cpp index 620a41cb6..ea6219e6a 100644 --- a/src/votv-coop/src/coop/props/container_write_policy.cpp +++ b/src/votv-coop/src/coop/props/container_write_policy.cpp @@ -82,8 +82,8 @@ Decision JudgeAgainstState(uint32_t eid, uint64_t baseHash, uint64_t nowMs) { return Judge(in); } -Decision Accept(uint32_t eid, uint64_t baseHash, uint8_t authorSlot, uint64_t nowMs, - coop::net::Session& session, Source src) { +Decision SpendArrival(uint32_t eid, uint8_t authorSlot, uint64_t nowMs, coop::net::Session& session, + Source src) { // The budget is spent by ARRIVAL, not by acceptance: a refused slice still costs the host a // parse, this arbitration and a corrective re-publish. A replay out of the park spends // nothing -- it arrived once already, and a pen sweeping four times a second would otherwise @@ -114,6 +114,12 @@ Decision Accept(uint32_t eid, uint64_t baseHash, uint8_t authorSlot, uint64_t no "arbitrated but not rate-bounded", static_cast(authorSlot)); } } + return Decision::Accept; +} + +Decision Accept(uint32_t eid, uint64_t baseHash, uint8_t authorSlot, uint64_t nowMs, + coop::net::Session& session, Source src) { + if (SpendArrival(eid, authorSlot, nowMs, session, src) == Decision::TooFast) return Decision::TooFast; // REACH, and only where it is answerable. A container's contents are mutated through the // player's own look-at trace, so an author that is neither at the container nor at an actor that diff --git a/src/votv-coop/src/coop/props/host_spawn_watcher.cpp b/src/votv-coop/src/coop/props/host_spawn_watcher.cpp index 9447a7dfb..42b5c8b3a 100644 --- a/src/votv-coop/src/coop/props/host_spawn_watcher.cpp +++ b/src/votv-coop/src/coop/props/host_spawn_watcher.cpp @@ -19,6 +19,7 @@ #include "ue_wrap/core/game_thread.h" #include "ue_wrap/core/hot_path_guard.h" // UE_ASSERT_GAME_THREAD #include "ue_wrap/core/log.h" +#include "ue_wrap/actors/container_inventory.h" // IsContainer (a tracked container still owes its contents) #include "ue_wrap/actors/prop.h" // IsDescendantOfProp (the keyed-prop gate for the Q-menu seam) #include "ue_wrap/core/reflection.h" #include "ue_wrap/core/sdk_profile.h" @@ -234,15 +235,19 @@ void OnFinishSpawnFunc(void* /*context*/, void* /*srcObj*/, void* result) { // expressing a mirror as a fresh world prop is the dupe. if (coop::prop_echo_suppress::IsMirrorSpawn(actor)) return; if (!ue_wrap::prop::IsDescendantOfProp(actor)) return; // KEYED Aprop_C lineage only - // An actor the init seam already tracked has its row out; only a fall is left to give it, so it - // is queued when it can fall (a spawn-menu barrel takes that route) and dropped here otherwise, - // which keeps every other tracked birth out of the queue and its cap. - if (PT::GetPropElementIdForActor(actor) != coop::element::kInvalidId && - !(coop::prop_wire_parity::PhysFlagsOf(actor) & coop::net::propspawn_flags::kSimulatePhysics)) + // An actor the init seam already tracked has its row out; only a fall and a container's contents + // are left to give it, so it is queued when it can fall (a spawn-menu barrel takes that route) or + // is a container (a thrown one is rebound to its slot by its own loadData, and no verb marks it), + // and dropped here otherwise, which keeps every other tracked birth out of the queue and its cap. + const bool tracked = PT::GetPropElementIdForActor(actor) != coop::element::kInvalidId; + if (tracked && + !(coop::prop_wire_parity::PhysFlagsOf(actor) & coop::net::propspawn_flags::kSimulatePhysics) && + !ue_wrap::container_inventory::IsContainer(actor)) return; if (g_pendingFinished.size() >= kMaxPendingFinished) { - UE_LOGW("host_spawn_watcher: pending-finished cap %zu hit -- dropping %p (safety census catches it)", - kMaxPendingFinished, actor); + UE_LOGW("host_spawn_watcher: pending-finished cap %zu hit -- dropping %p (%s)", kMaxPendingFinished, + actor, tracked ? "tracked: its fall and its contents are not given" + : "the safety census expresses it"); return; } g_pendingFinished.push_back(PendingFinishedSpawn{actor, R::InternalIndexOf(actor)}); From 07e8af8d008bd7a33c8566678e9508fc1d0fb874 Mon Sep 17 00:00:00 2001 From: pelmentor Date: Sat, 10 Oct 2026 02:11:53 +0600 Subject: [PATCH 038/306] [docs] the entity map names a client birth's wait and a thrown slice A prop the host spawns from a client's intent publishes nothing until the client's record lands, and the land publishes it. A container's contents are not its record: a thrown container's slice travels from the thrower once the host's echo binds it. Co-Authored-By: Claude Opus 5.5 --- docs/coop-entity-expression-map.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/docs/coop-entity-expression-map.md b/docs/coop-entity-expression-map.md index 224884d29..285d2a24b 100644 --- a/docs/coop-entity-expression-map.md +++ b/docs/coop-entity-expression-map.md @@ -93,7 +93,15 @@ are peer-gated. `[V]` a mirror never starts from a class default. Membership is the class declaring a `getData` below `Aprop_C`, read off the live class chain rather than from a list. A record whose prop has not arrived parks by Key with no expiry: an element id names an actor, and the whole reason the - record has to travel is that the actor is destroyed and remade. `[V]` + record has to travel is that the actor is destroyed and remade. `[V]` For a birth the host + spawns from a client's intent, the client's record is the record: the host awaits it and + publishes nothing for the key until it LANDS, applied at once or from the park, and the land + publishes it; a joiner seeded inside the wait gets it from that publish. `[V]` +- **A container's contents are not its record.** They are a slice of the shared GObjStack, the + contents lane's ([devices.md](devices.md)). A thrown container is rebound to its slot of the + thrower's own array by `loadData`, with no verb fired, so its contents travel as the thrower's + birth slice after the host's echo binds it, and the host's copy is a transfer in progress until + that slice lands. A container the host throws publishes its slice at its express. `[RD]` - **Destroy.** The engine's destroy call is caught before it runs on either role, on every route, at the native seam; a Blueprint-internal vanish (the truck, culling, a lifespan) is caught by the host's reaper death-watch and destroyed by id. `[V]` A floppy disc inserted into From 24e55a035ffe2019c23fbd622880d371a0d9dcd9 Mon Sep 17 00:00:00 2001 From: Shinobu2 Date: Sun, 4 Oct 2026 18:15:55 -0700 Subject: [PATCH 039/306] [coop] a peer's puppet takes no damage or fire here, and shows its flame Fire damages a player through the damage verb, which the body's own burn loop runs once a second, and a puppet set alight here burned for the session: the damage ran against this machine's player, its pain sound played and this machine's camera turned. The damage verb and the ignite verbs are refused on any peer's puppet, whoever calls, as the impact entries are: the owner computes its own. The owner's burningTime rides the pose stream as a display bit, and the puppet shows the body's flame. damage_probe=1 logs every call of the damage verb. The net of the five commits below without the fire-touch relay; build 221. Co-Authored-By: Claude Opus 5.5 (cherry picked from commit 32f635ed9ca48002b51e44521f81dd1f2cbc2042) (cherry picked from commit d516257d8f36cf4c48adb9928d06205130ffecda) (cherry picked from commit ad070b1fc69383a64313336410139f53b8f024ed) (cherry picked from commit 22854f5ae05059616c09581067dcb7ab5a152a63) (cherry picked from commit 6401b2ca0b8f1166bf9eff83423b7d07bc9feee5) --- docs/players.md | 8 +- .../coop/config/config_registry_rows_dev.inc | 4 + src/votv-coop/include/coop/dev/damage_probe.h | 13 ++++ src/votv-coop/include/coop/net/protocol.h | 3 +- .../include/coop/player/remote_player.h | 9 +++ src/votv-coop/sources.cmake | 1 + src/votv-coop/src/coop/dev/damage_probe.cpp | 78 +++++++++++++++++++ src/votv-coop/src/coop/dev/dev_lanes.cpp | 2 + .../src/coop/player/local_streams.cpp | 15 ++++ .../src/coop/player/player_damage.cpp | 67 ++++++++++++++++ .../src/coop/player/remote_player.cpp | 30 +++++++ 11 files changed, 228 insertions(+), 2 deletions(-) create mode 100644 src/votv-coop/include/coop/dev/damage_probe.h create mode 100644 src/votv-coop/src/coop/dev/damage_probe.cpp diff --git a/docs/players.md b/docs/players.md index 7f8b13bd6..1f5a28ea9 100644 --- a/docs/players.md +++ b/docs/players.md @@ -127,7 +127,13 @@ impact (a vehicle, a thrown prop, a coin) dispatches the game's impact events on was hit, and the game's health is a per-machine singleton, so an impact resolving against a puppet in the host's world would drain the host; an interceptor cancels the three impact entries on any body that is not the local possessed player, and the victim's own machine computes the -same contact natively (`coop/player/player_damage`). Enemy hits are the one relay: the host runs +same contact natively (`coop/player/player_damage`). The damage verb itself is refused on a +peer's puppet the same way: fire and the other status effects tick it from the burning body's own +graph, and a puppet set alight by a fire only this machine shows burned on and shook this +machine's camera once a second. The body's own ignite, attempt-ignite and start-burning +functions are refused on a puppet too: its burn loop turns this machine's view and plays its pain +sound every second, and only its own tick, which a puppet does not run, would put it out. The fire +is not yet a shared hazard: the owner does not burn from a fire only another machine shows. Enemy hits are the one relay: the host runs the enemies, and when one hits a peer's puppet the host sends that peer a reliable damage event, which the peer applies on its own pawn, armour and inventory mitigation included, so its health stream and hurt flash follow. diff --git a/src/votv-coop/include/coop/config/config_registry_rows_dev.inc b/src/votv-coop/include/coop/config/config_registry_rows_dev.inc index cdbe6c934..91512cc80 100644 --- a/src/votv-coop/include/coop/config/config_registry_rows_dev.inc +++ b/src/votv-coop/include/coop/config/config_registry_rows_dev.inc @@ -152,6 +152,10 @@ CFG_FLAG(death_seam_census, "death_seam_census", "dev", false, "VOTVCOOP_DEATH_S "Probe: every element end the death seam announces, one [DEATH] line each -- the element's type " "and id, mirror or local, destroyed or streamed out, and its class -- to pair with the lanes' own " "death detections. Read-only.") +CFG_FLAG(damage_probe, "damage_probe", "dev", false, "VOTVCOOP_DAMAGE_PROBE", + "Probe (BOTH peers): every call of the player's damage verb, one [DMG] line each -- this machine's " + "own player or a peer's puppet, the amount, the source's class and the calling Blueprint function. " + "Read-only.") CFG_FLAG(event_drill, "event_drill", "dev", false, "VOTVCOOP_EVENT_DRILL", "Drill (BOTH peers): the host fires the meteor shower before the client's world is up (the join " "snapshot carries it) and then through the scheduler itself (the clock set to day 2 00:18), then " diff --git a/src/votv-coop/include/coop/dev/damage_probe.h b/src/votv-coop/include/coop/dev/damage_probe.h new file mode 100644 index 000000000..54d7845ec --- /dev/null +++ b/src/votv-coop/include/coop/dev/damage_probe.h @@ -0,0 +1,13 @@ +// coop/dev/damage_probe.h -- [dev] every call of the player's damage verb on this machine, one [DMG] +// line each: whose body takes it (this machine's own player, a peer's puppet by slot, or another +// actor), the amount, the source argument's class, and the Blueprint function and object that called +// it. It answers which route a hazard reaches a player by, and on which machine, before a relay is +// built on it. Armed by damage_probe=1; read-only, it never refuses a call. +#pragma once + +namespace coop::dev::damage_probe { + +// Registers the watch once, when armed; a latched flag read when off. Game thread. +void Tick(); + +} // namespace coop::dev::damage_probe diff --git a/src/votv-coop/include/coop/net/protocol.h b/src/votv-coop/include/coop/net/protocol.h index a8a332c57..ce2993db0 100644 --- a/src/votv-coop/include/coop/net/protocol.h +++ b/src/votv-coop/include/coop/net/protocol.h @@ -30,7 +30,7 @@ inline constexpr uint32_t kMagic = 0x564D5450u; // This file is past the 1500-line hard cap and stays there: it is the single-feature exception the // rule names. One wire format, whose enum, payload structs and static_asserts are read together; // splitting it would put a kind's number in one file and its bytes in another. -inline constexpr uint16_t kProtocolVersion = 220; +inline constexpr uint16_t kProtocolVersion = 221; // Default LAN port (overridable via multivoid.ini "net.port="). inline constexpr uint16_t kDefaultPort = 47621; @@ -1075,6 +1075,7 @@ namespace match_form { constexpr uint8_t kNone = 0; constexpr uint8_t kPile = 1; // PoseSnapshot.stateBits flags. Single-byte field; flags assigned bit-by-bit. inline constexpr uint8_t kStateBitInAir = 0x01; inline constexpr uint8_t kStateBitRagdoll = 0x02; // the source is ragdolled (faint, manual, knock-out), not dead +inline constexpr uint8_t kStateBitBurning = 0x04; // the source's body is on fire (its burningTime runs), display only // The game's vital scalars (food, sleep, the default max health) top out at 100. health is // normalised by the peer's own max health before quantisation; food and sleep by this. diff --git a/src/votv-coop/include/coop/player/remote_player.h b/src/votv-coop/include/coop/player/remote_player.h index 9556d15fd..3f6ecfdb4 100644 --- a/src/votv-coop/include/coop/player/remote_player.h +++ b/src/votv-coop/include/coop/player/remote_player.h @@ -8,6 +8,8 @@ #pragma once +#include + #include "coop/element/lerp_window.h" #include "coop/net/protocol.h" #include "coop/player/puppet_body_yaw.h" @@ -109,6 +111,7 @@ class RemotePlayer { int32_t RagdollBodyIdx() const { return ragdoll_.BodyIdx(); } void* actor() const { return actor_; } + void ShowBurning(bool burning); private: // Apply curPos_, curYaw_ and curSpeed_ to the engine actor and the AnimBP. Called by Tick once @@ -200,6 +203,12 @@ class RemotePlayer { // teardown) lives in coop/player/remote_player_ragdoll.h; the glue here keys off its returns // (a stop resets bodyYaw_, an applied pose sets dirty_). Torn down in Destroy. RagdollDisplay ragdoll_; + // The flame shown on this puppet, from the owner's burning bit: only the body's burningEffect + // component, switched on and off; never ignite, its damage loop, its 2D pain sound or its camera + // turn, which are the owner's. Re-asserted while the bit holds, so a respawned body shows it again. + bool burningShown_ = false; + bool burningTried_ = false; // a switch to the other state was attempted and not yet taken + std::chrono::steady_clock::time_point burningNextTry_{}; ue_wrap::FVector targetPos_{}; float targetYaw_ = 0.f; float targetPitch_ = 0.f; diff --git a/src/votv-coop/sources.cmake b/src/votv-coop/sources.cmake index faecbc858..56b5d9288 100644 --- a/src/votv-coop/sources.cmake +++ b/src/votv-coop/sources.cmake @@ -552,6 +552,7 @@ set(VOTVCOOP_SOURCES src/coop/dev/tower_drill.cpp src/coop/dev/end_play_probe.cpp src/coop/dev/death_seam_census.cpp + src/coop/dev/damage_probe.cpp src/coop/dev/grime_drill.cpp src/coop/dev/class_lifetime_probe.cpp src/coop/dev/init_seam_probe.cpp diff --git a/src/votv-coop/src/coop/dev/damage_probe.cpp b/src/votv-coop/src/coop/dev/damage_probe.cpp new file mode 100644 index 000000000..83fc01fb2 --- /dev/null +++ b/src/votv-coop/src/coop/dev/damage_probe.cpp @@ -0,0 +1,78 @@ +// coop/dev/damage_probe.cpp -- see coop/dev/damage_probe.h. +#include "coop/dev/damage_probe.h" + +#include "coop/config/config.h" +#include "coop/config/config_registry.h" +#include "coop/player/players_registry.h" +#include "ue_wrap/core/log.h" +#include "ue_wrap/core/reflection.h" +#include "ue_wrap/core/reflection_props.h" +#include "ue_wrap/core/script_gate.h" + +#include +#include +#include + +namespace coop::dev::damage_probe { +namespace { + +namespace R = ue_wrap::reflection; +namespace sg = ue_wrap::script_gate; + +// The verb wisp_attack_sync watches too; a second watch on the same name with its own tag. +constexpr const wchar_t* kDamageVerbName = L"Add Player Damage"; +constexpr int kTag = 0x444D4750; // 'DMGP' + +std::wstring ClassOrNull(void* obj) { return obj ? R::ClassNameOf(obj) : std::wstring(L"null"); } + +sg::Verdict OnDamagePre(const sg::Call& c) { + if (!c.locals || !c.function) return sg::Verdict::Run; + // The parameter offsets, per function: a name watch may match an overriding subclass's verb. + static void* sFn = nullptr; + static int32_t sAmountOff = -1; + static int32_t sSourceOff = -1; + if (c.function != sFn) { + sFn = c.function; + sAmountOff = -1; + for (const wchar_t* n : {L"damage", L"amount", L"dmg", L"value"}) { + sAmountOff = R::FindParamOffset(c.function, n); + if (sAmountOff >= 0) break; + } + sSourceOff = R::FindParamOffset(c.function, L"source"); + } + float amount = 0.f; + if (sAmountOff >= 0) std::memcpy(&amount, c.locals + sAmountOff, sizeof(amount)); + void* source = nullptr; + if (sSourceOff >= 0) std::memcpy(&source, c.locals + sSourceOff, sizeof(source)); + + auto& reg = coop::players::Registry::Get(); + std::string body; + if (c.object && c.object == reg.Local()) { + body = "LOCAL"; + } else if (c.object && reg.IsPuppet(c.object)) { + body = "PUPPET slot=" + std::to_string(reg.PeerIdOfActor(c.object)); + } else { + body = "OTHER"; + } + UE_LOGI("[DMG] %s body=%ls amount=%s%.2f source=%ls caller=%ls.%ls depth=%d%s", body.c_str(), + ClassOrNull(c.object).c_str(), sAmountOff >= 0 ? "" : "?", amount, ClassOrNull(source).c_str(), + ClassOrNull(c.callerObject).c_str(), + c.callerFunction ? R::ToString(R::NameOf(c.callerFunction)).c_str() : L"", c.depth, + c.fromOurCode ? " [ours]" : ""); + return sg::Verdict::Run; +} + +} // namespace + +void Tick() { + static const bool s_on = coop::config::ResolveFlag(::coop::config_registry::rows::damage_probe); + static bool s_watched = false; + if (!s_on || s_watched) return; + s_watched = true; + if (sg::WatchName(kDamageVerbName, kTag, &OnDamagePre, nullptr)) + UE_LOGI("[DMG] probe armed on '%ls'", kDamageVerbName); + else + UE_LOGW("[DMG] probe could not watch '%ls'", kDamageVerbName); +} + +} // namespace coop::dev::damage_probe diff --git a/src/votv-coop/src/coop/dev/dev_lanes.cpp b/src/votv-coop/src/coop/dev/dev_lanes.cpp index 478b81d4c..2d5f59914 100644 --- a/src/votv-coop/src/coop/dev/dev_lanes.cpp +++ b/src/votv-coop/src/coop/dev/dev_lanes.cpp @@ -11,6 +11,7 @@ #include "coop/dev/container_selftest.h" // [dev] container-lane e2e circle (organic addLoot) #include "coop/dev/container_view_drill.h" // [dev] a container view closes as its opener leaves reach #include "coop/dev/death_seam_census.h" // [dev] every element end the death seam announces +#include "coop/dev/damage_probe.h" // [dev] every player damage verb call, and by whom #include "coop/dev/delivery_census_probe.h" // COUNT the delivery-path actors #include "coop/dev/desk_diag.h" // [dev] desk/console divergence census #include "coop/dev/door_drill.h" // [dev] whether a remote player counts in a door's own sensor @@ -247,6 +248,7 @@ void TickDrills(coop::net::Session& session) { coop::dev::run_and_wait_selftest::Tick(); // [dev] the wait's endings, once (single latched read when off) coop::dev::end_play_probe::Tick(); // [dev] the end-of-play seam against the K2 seam (single latched read when off) coop::dev::death_seam_census::Tick(); // [dev] every element end the death seam announces (latched read when off) + coop::dev::damage_probe::Tick(); // [dev] every player damage verb call: whose body, source, caller (latched read when off) coop::dev::grime_drill::Tick(&session); // [dev] the grime drill's legs (a latched read when off) coop::dev::vitals_keepalive::Tick(); // [dev] long-exposure keepalive (single latched read when off) } diff --git a/src/votv-coop/src/coop/player/local_streams.cpp b/src/votv-coop/src/coop/player/local_streams.cpp index 56610d414..06970c7a5 100644 --- a/src/votv-coop/src/coop/player/local_streams.cpp +++ b/src/votv-coop/src/coop/player/local_streams.cpp @@ -30,6 +30,7 @@ #include #include +#include #include namespace coop::local_streams { @@ -112,6 +113,20 @@ bool ReadLocalPose(void* local, void* controller, coop::net::PoseSnapshot& out) out.stateBits |= coop::net::kStateBitRagdoll; } } + // The burning bit: the body's own fire runs while its burningTime is above zero (the condition + // mainPlayer_C's tick tests before it turns the flame off), so the other screens show the flame on + // the puppet. Display only: a receiver never ignites anything. + { + static void* sCls = nullptr; + static int32_t sOffBurn = -1; + void* cls = ue_wrap::reflection::ClassOf(local); + if (cls != sCls) { sCls = cls; sOffBurn = ue_wrap::reflection::FindPropertyOffset(cls, L"burningTime"); } + if (sOffBurn >= 0) { + float burn = 0.f; + std::memcpy(&burn, static_cast(local) + sOffBurn, sizeof(burn)); + if (burn > 0.f) out.stateBits |= coop::net::kStateBitBurning; + } + } // The vitals (health fraction, food, sleep) in three bytes: ue_wrap::vitals reads this // machine's UsaveSlot_C, so each peer packs its own. Full (255) until the save resolves, so a // booting peer does not flash an empty bar; the receiver treats them as display only. O(1) diff --git a/src/votv-coop/src/coop/player/player_damage.cpp b/src/votv-coop/src/coop/player/player_damage.cpp index c0b1dc13f..88e696a27 100644 --- a/src/votv-coop/src/coop/player/player_damage.cpp +++ b/src/votv-coop/src/coop/player/player_damage.cpp @@ -17,6 +17,7 @@ #include "ue_wrap/core/log.h" #include "ue_wrap/core/object_index.h" #include "ue_wrap/core/reflection.h" +#include "ue_wrap/core/script_gate.h" #include "ue_wrap/actors/vitals.h" #include @@ -78,6 +79,60 @@ bool OnImpactEntryPre(void* self, void* /*params*/) { return true; // cancel: the singleton saveSlot write must not run for a foreign body } +// The same rule for the damage verb itself. Fire, burning and the other status effects do not come +// through an impact entry: the body's own ubergraph calls Add Player Damage on itself once a second +// for as long as it burns. A peer's puppet that walked through fire only this machine shows caught +// it here and burned for the rest of the session, and every tick ran the damage body against this +// machine's player: its camera shook once a second (measured with coop/dev/damage_probe: [DMG] +// PUPPET lines at 1 Hz for seven minutes). The puppet's owner sees no such fire and takes nothing, +// which is the victim-authoritative answer the impact cancel already gives. Watched at the +// script-body gate because the verb is Blueprint-internal on every route. The mod's own calls get no +// pass: the origin flag spans every Blueprint call nested inside one of ours, so it proves nothing +// about which body a nested damage or ignite was aimed at. A puppet is refused whoever asks. +constexpr const wchar_t* kDamageVerbName = L"Add Player Damage"; +constexpr int kDamageVerbTag = 0x504C4447; // 'PLDG' +bool g_damageWatchDone = false; +uint32_t g_verbCanceled = 0; + +ue_wrap::script_gate::Verdict OnDamageVerbPre(const ue_wrap::script_gate::Call& c) { + auto* s = g_session.load(std::memory_order_acquire); + if (!s || !s->connected() || !c.object) return ue_wrap::script_gate::Verdict::Run; + void* localPawn = g_localPawn.load(std::memory_order_acquire); + if (!localPawn || c.object == localPawn) return ue_wrap::script_gate::Verdict::Run; + if (!coop::players::Registry::Get().IsPuppet(c.object)) return ue_wrap::script_gate::Verdict::Run; + const uint32_t n = ++g_verbCanceled; + if (n <= 5 || (n % 100) == 0) + UE_LOGI("player_damage: canceled the damage verb on a peer's puppet %p (#%u) -- its owner " + "computes its own damage", c.object, n); + return ue_wrap::script_gate::Verdict::Cancel; +} + +// The body never catches fire either. mainPlayer_C burns on its own: ignite(fuel) sets burningTime and +// starts a once-a-second loop that runs the damage verb, plays a 2D pain sound and turns +// GetPlayerController(0) -- this machine's camera -- by a random ten degrees; burningTime only runs out +// on the burning body's own tick, which a puppet does not run, so a puppet set alight burned for the +// rest of the session and shook this machine's view every second. The damage cancel above stops the +// health write and nothing else, so a peer's puppet is refused the three ways in: the owner computes +// its own fire as it computes its own damage. Read from mainPlayer_C's own bytecode, decompiled with +// UAssetGUI: the ignite, attemptIgnite and startBurning functions and the burningTime loop. +const wchar_t* const kIgniteNames[3] = {L"ignite", L"attemptIgnite", L"startBurning"}; +constexpr int kIgniteTag = 0x49474E54; // 'IGNT' +bool g_igniteWatchDone = false; +uint32_t g_igniteCanceled = 0; + +ue_wrap::script_gate::Verdict OnIgnitePre(const ue_wrap::script_gate::Call& c) { + auto* s = g_session.load(std::memory_order_acquire); + if (!s || !s->connected() || !c.object) return ue_wrap::script_gate::Verdict::Run; + void* localPawn = g_localPawn.load(std::memory_order_acquire); + if (!localPawn || c.object == localPawn) return ue_wrap::script_gate::Verdict::Run; + if (!coop::players::Registry::Get().IsPuppet(c.object)) return ue_wrap::script_gate::Verdict::Run; + const uint32_t n = ++g_igniteCanceled; + if (n <= 5 || (n % 100) == 0) + UE_LOGI("player_damage: refused setting a peer's puppet %p alight (#%u) -- its fire is its owner's", + c.object, n); + return ue_wrap::script_gate::Verdict::Cancel; +} + } // namespace void Install(coop::net::Session* session) { @@ -88,6 +143,18 @@ void Tick() { // Publish the local pawn for the interceptor's any-thread compare (GT here; // Registry::Local() is GT-only and cached). g_localPawn.store(coop::players::Registry::Get().Local(), std::memory_order_release); + if (!g_damageWatchDone) { + g_damageWatchDone = true; + if (!ue_wrap::script_gate::WatchName(kDamageVerbName, kDamageVerbTag, &OnDamageVerbPre, nullptr)) + UE_LOGE("player_damage: the damage verb watch did not register -- a burning puppet damages " + "this machine's player"); + } + if (!g_igniteWatchDone) { + g_igniteWatchDone = true; + for (const wchar_t* name : kIgniteNames) + if (!ue_wrap::script_gate::WatchClassName(L"mainPlayer_C", name, kIgniteTag, &OnIgnitePre, nullptr)) + UE_LOGE("player_damage: the %ls watch did not register -- a peer's puppet can burn here", name); + } if (g_impactInterceptorsDone) return; void* cls = ue_wrap::object_index::ClassByName(L"mainPlayer_C"); if (!cls) return; diff --git a/src/votv-coop/src/coop/player/remote_player.cpp b/src/votv-coop/src/coop/player/remote_player.cpp index 298b4699d..1899cd6ec 100644 --- a/src/votv-coop/src/coop/player/remote_player.cpp +++ b/src/votv-coop/src/coop/player/remote_player.cpp @@ -11,6 +11,7 @@ #include "coop/player/skin_effects.h" #include "coop/player/players_registry.h" #include "ue_wrap/core/call.h" +#include "ue_wrap/core/component_calls.h" // SetActive: the puppet's burning flame #include "ue_wrap/engine/engine.h" #include "ue_wrap/engine/world_identity.h" #include "ue_wrap/core/log.h" @@ -237,6 +238,8 @@ void RemotePlayer::SetTargetPose(const coop::net::PoseSnapshot& snap) { bodyYaw_.Reset(curYaw_); } + ShowBurning((snap.stateBits & coop::net::kStateBitBurning) != 0); + const ue_wrap::FVector tgtPos{snap.x, snap.y, snap.z}; // The first packet: the puppet sits at the placeholder placement, so snap rather than @@ -384,8 +387,35 @@ void RemotePlayer::SetRagdollPose(const coop::net::RagdollPoseSnapshot& snap) { if (ragdoll_.SetPose(snap)) dirty_ = true; } +void RemotePlayer::ShowBurning(bool burning) { + // The wanted state is the owner's bit; the shown state changes only when the flame switch + // succeeded. Either direction is retried about once a second until it takes, and a held ON is + // re-asserted at the same cadence, which covers a body whose components were rebuilt. + const auto now = std::chrono::steady_clock::now(); + const bool due = now >= burningNextTry_; + if (burning == burningShown_ && !(burning && due)) return; + if (burning != burningShown_ && !due && burningTried_) return; + burningNextTry_ = now + std::chrono::seconds(1); + burningTried_ = true; + if (!actor_ || !R::IsLiveByIndex(actor_, internalIdx_)) return; + static void* sCls = nullptr; + static int32_t sOffEffect = -1; + void* cls = R::ClassOf(actor_); + if (cls != sCls) { sCls = cls; sOffEffect = R::FindPropertyOffset(cls, L"burningEffect"); } + if (sOffEffect < 0) return; + void* effect = *reinterpret_cast(static_cast(actor_) + sOffEffect); + if (!effect || !R::IsLive(effect)) return; + if (!ue_wrap::component_calls::SetActive(effect, burning, /*reset=*/false)) return; + if (burning != burningShown_) { + UE_LOGI("remote_player: puppet %p flame %s (its owner's burning bit)", actor_, burning ? "on" : "off"); + burningTried_ = false; + } + burningShown_ = burning; +} + void RemotePlayer::Destroy() { if (!actor_) return; + burningShown_ = false; // The AnimInstance dies with the actor, so no field cleanup. The ragdoll display body is a // separate actor that would outlive the puppet as an orphan: torn down first, with its latches. ragdoll_.TeardownForDestroy(); From ceb639096ba474fafb7f3d5128fca8ffa38fc98c Mon Sep 17 00:00:00 2001 From: pelmentor Date: Sat, 10 Oct 2026 02:34:33 +0600 Subject: [PATCH 040/306] [coop] only ignite reaches a puppet, and its flame goes through ue_wrap attemptIgnite, the verb a spreading fire calls, is an empty stub on the player (its ubergraph entry is a bare pop, bp_cfg), and startBurning is private and reached from ignite alone, so the two watches on them could never fire: only ignite is watched. The burning bit reads burningTime through ue_wrap::vitals and the puppet's flame is switched by ue_wrap::puppet::SetBurningFlame, rather than by raw offsets in coop/. A destroyed puppet forgets its flame retry too. The pose bit comment and players.md name the bit and drop the two dead verbs. Co-Authored-By: Claude Opus 5.5 --- docs/players.md | 12 +++++++----- src/votv-coop/include/coop/net/protocol.h | 3 ++- src/votv-coop/include/ue_wrap/actors/puppet.h | 5 +++++ src/votv-coop/src/coop/player/local_streams.cpp | 16 +++++----------- src/votv-coop/src/coop/player/player_damage.cpp | 17 ++++++++++------- src/votv-coop/src/coop/player/remote_player.cpp | 12 +++--------- src/votv-coop/src/ue_wrap/actors/puppet.cpp | 13 +++++++++++++ 7 files changed, 45 insertions(+), 33 deletions(-) diff --git a/docs/players.md b/docs/players.md index 1f5a28ea9..ae13f9da1 100644 --- a/docs/players.md +++ b/docs/players.md @@ -130,11 +130,13 @@ on any body that is not the local possessed player, and the victim's own machine same contact natively (`coop/player/player_damage`). The damage verb itself is refused on a peer's puppet the same way: fire and the other status effects tick it from the burning body's own graph, and a puppet set alight by a fire only this machine shows burned on and shook this -machine's camera once a second. The body's own ignite, attempt-ignite and start-burning -functions are refused on a puppet too: its burn loop turns this machine's view and plays its pain -sound every second, and only its own tick, which a puppet does not run, would put it out. The fire -is not yet a shared hazard: the owner does not burn from a fire only another machine shows. Enemy hits are the one relay: the host runs -the enemies, and when one hits a peer's puppet the host sends that peer a reliable damage event, +machine's camera once a second. The body's own ignite is refused on a puppet too, the one way a +fire sets a player alight: its burn loop turns this machine's view and plays its pain sound every +second, and only its own tick, which a puppet does not run, would put it out. The fire is not yet +a shared hazard: the owner does not burn from a fire only another machine shows, until fire itself +is host-authored world state. A burning owner's flame is shown: its burning time rides the pose +stream as a display bit, and the puppet switches the body's own flame on and off, nothing else. +Enemy hits are the one relay: the host runs the enemies, and when one hits a peer's puppet the host sends that peer a reliable damage event, which the peer applies on its own pawn, armour and inventory mitigation included, so its health stream and hurt flash follow. diff --git a/src/votv-coop/include/coop/net/protocol.h b/src/votv-coop/include/coop/net/protocol.h index ce2993db0..6cb3af3be 100644 --- a/src/votv-coop/include/coop/net/protocol.h +++ b/src/votv-coop/include/coop/net/protocol.h @@ -1053,7 +1053,8 @@ inline uint32_t NowStateTimeMs24() { // speed -- horizontal velocity magnitude (cm/s), the locomotion blend input. // stateBits -- bit 0 in air (the source's movement mode is falling; clears the puppet's foot // IK), bit 1 ragdolled and not dead (every ragdoll cause; the receiver toggles -// ragdollMode and forceGetUp on the edges), bits 2..7 reserved. +// ragdollMode and forceGetUp on the edges), bit 2 burning (the source's burningTime +// runs; the receiver shows the flame and nothing else), bits 3..7 reserved. struct PoseSnapshot { float x, y, z; float yaw; diff --git a/src/votv-coop/include/ue_wrap/actors/puppet.h b/src/votv-coop/include/ue_wrap/actors/puppet.h index ffe6e26a3..dab1dc7fd 100644 --- a/src/votv-coop/include/ue_wrap/actors/puppet.h +++ b/src/votv-coop/include/ue_wrap/actors/puppet.h @@ -134,4 +134,9 @@ void DisableCharacterTicks(void* actor); // drives that edge itself (ue_wrap::wisp::DriveWispLanding). No-op on a null or dead actor. void DisableMovementTick(void* actor); +// The body's own flame (mainPlayer_C.burningEffect) switched on or off -- the component alone, never +// ignite, its damage loop, its pain sound or its camera turn, which are the owner's. False when the +// component does not resolve or the switch did not take. Game thread. +bool SetBurningFlame(void* mainPlayerActor, bool on); + } // namespace ue_wrap::puppet diff --git a/src/votv-coop/src/coop/player/local_streams.cpp b/src/votv-coop/src/coop/player/local_streams.cpp index 06970c7a5..d6776d815 100644 --- a/src/votv-coop/src/coop/player/local_streams.cpp +++ b/src/votv-coop/src/coop/player/local_streams.cpp @@ -30,7 +30,6 @@ #include #include -#include #include namespace coop::local_streams { @@ -115,17 +114,12 @@ bool ReadLocalPose(void* local, void* controller, coop::net::PoseSnapshot& out) } // The burning bit: the body's own fire runs while its burningTime is above zero (the condition // mainPlayer_C's tick tests before it turns the flame off), so the other screens show the flame on - // the puppet. Display only: a receiver never ignites anything. + // the puppet. Display only: a receiver never ignites anything. ue_wrap::vitals reads it off the + // gamemode's own player, which is `local`. { - static void* sCls = nullptr; - static int32_t sOffBurn = -1; - void* cls = ue_wrap::reflection::ClassOf(local); - if (cls != sCls) { sCls = cls; sOffBurn = ue_wrap::reflection::FindPropertyOffset(cls, L"burningTime"); } - if (sOffBurn >= 0) { - float burn = 0.f; - std::memcpy(&burn, static_cast(local) + sOffBurn, sizeof(burn)); - if (burn > 0.f) out.stateBits |= coop::net::kStateBitBurning; - } + float burn = 0.f; + if (ue_wrap::vitals::Read(ue_wrap::vitals::Field::BurningTime, &burn) && burn > 0.f) + out.stateBits |= coop::net::kStateBitBurning; } // The vitals (health fraction, food, sleep) in three bytes: ue_wrap::vitals reads this // machine's UsaveSlot_C, so each peer packs its own. Full (255) until the save resolves, so a diff --git a/src/votv-coop/src/coop/player/player_damage.cpp b/src/votv-coop/src/coop/player/player_damage.cpp index 88e696a27..a1bada1dc 100644 --- a/src/votv-coop/src/coop/player/player_damage.cpp +++ b/src/votv-coop/src/coop/player/player_damage.cpp @@ -112,10 +112,14 @@ ue_wrap::script_gate::Verdict OnDamageVerbPre(const ue_wrap::script_gate::Call& // GetPlayerController(0) -- this machine's camera -- by a random ten degrees; burningTime only runs out // on the burning body's own tick, which a puppet does not run, so a puppet set alight burned for the // rest of the session and shook this machine's view every second. The damage cancel above stops the -// health write and nothing else, so a peer's puppet is refused the three ways in: the owner computes -// its own fire as it computes its own damage. Read from mainPlayer_C's own bytecode, decompiled with -// UAssetGUI: the ignite, attemptIgnite and startBurning functions and the burningTime loop. -const wchar_t* const kIgniteNames[3] = {L"ignite", L"attemptIgnite", L"startBurning"}; +// health write and nothing else, so ignite is refused on a peer's puppet: the owner computes its own +// fire as it computes its own damage. It is the one way in. attemptIgnite, the verb a spreading fire +// calls, is an empty stub on the player (its ubergraph entry is a bare pop), and startBurning is +// private and reached from ignite alone (tools/bp_cfg.py mainPlayer --fn attemptIgnite; the +// mainPlayer offsets listing). Both refusals ask IsPuppet rather than "not the local pawn", the +// impact cancel's test, because a stale local pawn across a respawn must never make this machine's +// own player immune. +constexpr const wchar_t* kIgniteName = L"ignite"; constexpr int kIgniteTag = 0x49474E54; // 'IGNT' bool g_igniteWatchDone = false; uint32_t g_igniteCanceled = 0; @@ -151,9 +155,8 @@ void Tick() { } if (!g_igniteWatchDone) { g_igniteWatchDone = true; - for (const wchar_t* name : kIgniteNames) - if (!ue_wrap::script_gate::WatchClassName(L"mainPlayer_C", name, kIgniteTag, &OnIgnitePre, nullptr)) - UE_LOGE("player_damage: the %ls watch did not register -- a peer's puppet can burn here", name); + if (!ue_wrap::script_gate::WatchClassName(L"mainPlayer_C", kIgniteName, kIgniteTag, &OnIgnitePre, nullptr)) + UE_LOGE("player_damage: the ignite watch did not register -- a peer's puppet can burn here"); } if (g_impactInterceptorsDone) return; void* cls = ue_wrap::object_index::ClassByName(L"mainPlayer_C"); diff --git a/src/votv-coop/src/coop/player/remote_player.cpp b/src/votv-coop/src/coop/player/remote_player.cpp index 1899cd6ec..c3a1d0a11 100644 --- a/src/votv-coop/src/coop/player/remote_player.cpp +++ b/src/votv-coop/src/coop/player/remote_player.cpp @@ -11,7 +11,6 @@ #include "coop/player/skin_effects.h" #include "coop/player/players_registry.h" #include "ue_wrap/core/call.h" -#include "ue_wrap/core/component_calls.h" // SetActive: the puppet's burning flame #include "ue_wrap/engine/engine.h" #include "ue_wrap/engine/world_identity.h" #include "ue_wrap/core/log.h" @@ -398,14 +397,7 @@ void RemotePlayer::ShowBurning(bool burning) { burningNextTry_ = now + std::chrono::seconds(1); burningTried_ = true; if (!actor_ || !R::IsLiveByIndex(actor_, internalIdx_)) return; - static void* sCls = nullptr; - static int32_t sOffEffect = -1; - void* cls = R::ClassOf(actor_); - if (cls != sCls) { sCls = cls; sOffEffect = R::FindPropertyOffset(cls, L"burningEffect"); } - if (sOffEffect < 0) return; - void* effect = *reinterpret_cast(static_cast(actor_) + sOffEffect); - if (!effect || !R::IsLive(effect)) return; - if (!ue_wrap::component_calls::SetActive(effect, burning, /*reset=*/false)) return; + if (!ue_wrap::puppet::SetBurningFlame(actor_, burning)) return; if (burning != burningShown_) { UE_LOGI("remote_player: puppet %p flame %s (its owner's burning bit)", actor_, burning ? "on" : "off"); burningTried_ = false; @@ -416,6 +408,8 @@ void RemotePlayer::ShowBurning(bool burning) { void RemotePlayer::Destroy() { if (!actor_) return; burningShown_ = false; + burningTried_ = false; + burningNextTry_ = {}; // The AnimInstance dies with the actor, so no field cleanup. The ragdoll display body is a // separate actor that would outlive the puppet as an orphan: torn down first, with its latches. ragdoll_.TeardownForDestroy(); diff --git a/src/votv-coop/src/ue_wrap/actors/puppet.cpp b/src/votv-coop/src/ue_wrap/actors/puppet.cpp index 2d98731c3..365edd27a 100644 --- a/src/votv-coop/src/ue_wrap/actors/puppet.cpp +++ b/src/votv-coop/src/ue_wrap/actors/puppet.cpp @@ -5,6 +5,7 @@ #include "ue_wrap/core/game_thread.h" #include "ue_wrap/core/log.h" #include "ue_wrap/core/cached_obj_ref.h" +#include "ue_wrap/core/component_calls.h" // SetActive (the body's flame) #include "ue_wrap/core/reflection.h" #include "ue_wrap/core/sdk_profile.h" #include "ue_wrap/core/reflected_offset.h" @@ -403,4 +404,16 @@ void DisableMovementTick(void* actor) { } } +bool SetBurningFlame(void* mainPlayerActor, bool on) { + if (!mainPlayerActor || !R::IsLive(mainPlayerActor)) return false; + static void* sCls = nullptr; + static int32_t sOffEffect = -1; + void* const cls = R::ClassOf(mainPlayerActor); + if (cls != sCls) { sCls = cls; sOffEffect = R::FindPropertyOffset(cls, L"burningEffect"); } + if (sOffEffect < 0) return false; + void* const effect = *reinterpret_cast(static_cast(mainPlayerActor) + sOffEffect); + if (!effect || !R::IsLive(effect)) return false; + return component_calls::SetActive(effect, on, /*reset=*/false); +} + } // namespace ue_wrap::puppet From 87c3428eca4b94a7e747eb2f301aab0d9176ef38 Mon Sep 17 00:00:00 2001 From: pelmentor Date: Sat, 10 Oct 2026 02:34:43 +0600 Subject: [PATCH 041/306] [coop] a container transfer is capped per author and held past the rate A birth slice past its author's arrival budget was dropped, and it is the thrower's only copy, sent once: it is now held in the pen and replayed, which spends nothing. The 64-transfer cap was shared by every author, so one client's throws evicted another's; each author now has its own. An expiry says the thrower's contents are lost to it, a first publication whose inventory never resolves is given up and said, and the awaited lookup returns at once when nothing is awaited. Co-Authored-By: Claude Opus 5.5 --- .../coop/props/container_contents_sync.h | 2 +- .../coop/props/container_contents_sync.cpp | 47 +++++++++++++------ .../src/coop/props/host_spawn_watcher.cpp | 2 +- 3 files changed, 35 insertions(+), 16 deletions(-) diff --git a/src/votv-coop/include/coop/props/container_contents_sync.h b/src/votv-coop/include/coop/props/container_contents_sync.h index 1538144fb..5791c2514 100644 --- a/src/votv-coop/include/coop/props/container_contents_sync.h +++ b/src/votv-coop/include/coop/props/container_contents_sync.h @@ -61,7 +61,7 @@ void NoteAuthoredBirth(void* actor); // progress. It is not published while awaited; the author's slice is taken without a base, merged // with whatever the host put in meanwhile, and published to every peer. void ExpectBirthSlice(void* actor, uint8_t authorSlot); -// HOST, host_spawn_watcher, at the express of the host's own birth: a container with contents +// HOST, host_spawn_watcher, when the drain takes the host's own birth: a container with contents // publishes them, since the mirrors are born empty. One awaited from a client says nothing. void NoteHostBirth(void* actor); // HOST: a transfer awaited from this slot ends with it. Game thread. diff --git a/src/votv-coop/src/coop/props/container_contents_sync.cpp b/src/votv-coop/src/coop/props/container_contents_sync.cpp index c36a3c73f..a3e44b930 100644 --- a/src/votv-coop/src/coop/props/container_contents_sync.cpp +++ b/src/votv-coop/src/coop/props/container_contents_sync.cpp @@ -98,7 +98,7 @@ std::atomic g_takeObjInFlight{false}; // published, and every slice of that author for it is the newest version of the transfer. Both // sides expire, so a lost intent leaves nothing behind. constexpr uint64_t kBirthTtlMs = 30000; -constexpr size_t kMaxBirths = 64; +constexpr size_t kMaxBirths = 64; // per author: one peer's throws never evict another's // The actor is held across ticks, so it is a CachedObjRef: a successor in the same slot at the same // address reads as dead rather than as the thrown container. struct Birth { @@ -242,6 +242,7 @@ void RelayToOthers(coop::net::Session* s, uint8_t authorSlot, const std::vector< // The awaited birth for this eid, from this author or (authorSlot 0) from any, or null. The host never // publishes an awaited container, so nothing a slice could have been based on exists yet. Birth* AwaitedBirth(uint32_t eid, uint8_t authorSlot) { + if (g_awaitedBirths.empty()) return nullptr; void* actor = LivePropActor(eid); if (!actor) return nullptr; const uint64_t now = NowMs(); @@ -261,13 +262,20 @@ void DropAwaitedBirth(uint32_t eid) { } } -// One more birth, the oldest given up (and said) past the cap: throws are human-rate, so the cap -// only bounds a peer that floods. +// One more birth, that author's oldest given up (and said) past the cap: throws are human-rate, so +// the cap only bounds a peer that floods, and only that peer pays for it. void PushBirth(std::vector& births, void* actor, uint8_t authorSlot) { - if (births.size() >= kMaxBirths) { - UE_LOGW("container_contents: %zu container transfers in flight -- the oldest is given up", - births.size()); - births.erase(births.begin()); + size_t mine = 0; + auto oldest = births.end(); + for (auto it = births.begin(); it != births.end(); ++it) { + if (it->authorSlot != authorSlot) continue; + if (oldest == births.end()) oldest = it; // pushed in order, so the first is the oldest + ++mine; + } + if (mine >= kMaxBirths) { + UE_LOGW("container_contents: slot %u has %zu container transfers in flight -- its oldest is " + "given up", static_cast(authorSlot), mine); + births.erase(oldest); } Birth b; b.actor.Set(actor); @@ -297,8 +305,9 @@ void SweepBirths(uint64_t now) { const bool live = b.actor.Alive(); if (live && now < b.deadlineMs) { ++i; continue; } if (live) - UE_LOGW("container_contents: the contents of a container slot %u threw never came -- it " - "stays as the host built it", static_cast(b.authorSlot)); + UE_LOGW("container_contents: the contents of a container slot %u threw never came -- the " + "host's copy stays as it built it, and the thrower's own contents are refused from now " + "on and lost to it", static_cast(b.authorSlot)); g_awaitedBirths.erase(g_awaitedBirths.begin() + static_cast(i)); } } @@ -329,9 +338,15 @@ void DrainDirty(coop::net::Session* s) { if (!ci::IsContainer(actor)) continue; void* inv = ci::InventoryOf(actor); if (!inv || !ci::IsWorldInventory(inv)) { // BOUNDARY 1 (fail-closed) - // A first publication owed for it waits for the component instead of ending here. + // A first publication owed for it waits for the component instead of ending here, and + // is given up, said, at its deadline. auto owed = g_owedFirst.find(eid); - if (owed != g_owedFirst.end() && NowMs() < owed->second) g_retry.insert(eid); + if (owed == g_owedFirst.end()) continue; + if (NowMs() < owed->second) { g_retry.insert(eid); continue; } + g_owedFirst.erase(owed); + g_birthEids.erase(eid); + UE_LOGW("container_contents: eid=%u -- its inventory never resolved; the first publication " + "is given up", eid); continue; } // A transfer in progress is not published: what the host added to it so far merges into the @@ -424,11 +439,15 @@ Ingest ParseAndApply(const std::vector& blob, uint32_t& outEid, uint8_t // reach to measure either: the throw put it where it is. const bool birth = IsHost() && senderSlot != 0 && AwaitedBirth(outEid, senderSlot) != nullptr; // Unjudged is not unbounded: a birth slice spends the same arrival budget an edit does, so an - // author cannot make the host parse slices for this container at any rate while it waits. + // author cannot make the host parse slices for this container at any rate while it waits. Past + // the budget it is HELD in the pen rather than refused: it is the thrower's only copy of the + // contents and is sent once, and a replay spends nothing, so the pen's own per-author cap and TTL + // are what bound it. if (birth) { auto* s = g_session.load(std::memory_order_acquire); - if (!s || wp::SpendArrival(outEid, senderSlot, NowMs(), *s, src) == wp::Decision::TooFast) - return Ingest::Handled; + if (!s) return Ingest::Handled; + if (wp::SpendArrival(outEid, senderSlot, NowMs(), *s, src) == wp::Decision::TooFast) + return Ingest::Park; } // Host arbitration before anything is touched; a refusal is answered by re-publishing the // host's truth to the author, so it converges instead of sitting on a divergent view. diff --git a/src/votv-coop/src/coop/props/host_spawn_watcher.cpp b/src/votv-coop/src/coop/props/host_spawn_watcher.cpp index 42b5c8b3a..79863c34b 100644 --- a/src/votv-coop/src/coop/props/host_spawn_watcher.cpp +++ b/src/votv-coop/src/coop/props/host_spawn_watcher.cpp @@ -416,7 +416,7 @@ void DrainPendingSpawns(coop::net::Session* s) { // world. Never adopted here. if (e.actor == coop::hand_item::LocalHandActor()) continue; // Tracked since the enqueue (the init seam expressed it first): someone owns its row, and - // only its fall is left to give a channel. + // only its fall and a container's contents are left to give it. if (coop::element::Registry::Get().EidForActor(e.actor) != coop::element::kInvalidId) { coop::props::container_contents_sync::NoteHostBirth(e.actor); CoastIfFalling(e.actor); From cc72b009a5585f57bbc0b67578ae3c61048c8144 Mon Sep 17 00:00:00 2001 From: pelmentor Date: Sat, 10 Oct 2026 02:37:23 +0600 Subject: [PATCH 042/306] [coop] a thrown container's transfer is its own module The transfer -- the thrower's birth slice, the host's awaited copy, the first publication owed for an unreadable slot -- had taken the contents lane past its 800-line cap. It moves to coop/props/container_birth, which owns that state and its four entry points; the drop intent, the spawn watcher and the disconnect call it directly. A move: the lane keeps the slice, the judgement and the apply, and behaves as before. Co-Authored-By: Claude Opus 5.5 --- .../include/coop/props/container_birth.h | 64 ++++++ .../coop/props/container_contents_sync.h | 16 -- src/votv-coop/sources.cmake | 1 + .../src/coop/props/container_birth.cpp | 185 ++++++++++++++++++ .../coop/props/container_contents_sync.cpp | 183 ++--------------- .../src/coop/props/host_spawn_watcher.cpp | 6 +- .../src/coop/props/prop_drop_intent.cpp | 7 +- src/votv-coop/src/coop/session/subsystems.cpp | 3 +- 8 files changed, 279 insertions(+), 186 deletions(-) create mode 100644 src/votv-coop/include/coop/props/container_birth.h create mode 100644 src/votv-coop/src/coop/props/container_birth.cpp diff --git a/src/votv-coop/include/coop/props/container_birth.h b/src/votv-coop/include/coop/props/container_birth.h new file mode 100644 index 000000000..59a4aeda6 --- /dev/null +++ b/src/votv-coop/include/coop/props/container_birth.h @@ -0,0 +1,64 @@ +// coop/props/container_birth.h -- a container that changes hands through the world. A throw respawns +// the container and the game's loadData rebinds it to its slot in the thrower's own GObjStack, firing +// neither addObject nor takeObj, and the drop intent carries no contents: the thrower's slice is the +// only copy of them, so it has to travel, and the host must not publish over it meanwhile. This +// module holds that transfer on both sides; coop/props/container_contents_sync ships, judges and +// applies the slices, and docs/devices.md carries the model. +// +// CLIENT: a container this peer threw is held until the host's echo binds it to an element; its +// slice is then owed to the host as a birth (container_slice_wire::kBirthBase). +// HOST: the copy built from a client's intent is a transfer in progress until that author's slice +// lands: not published, not seeded to a joiner, and every slice of that author for it is the newest +// version. It ends when the slice lands, when the author leaves, or at its deadline. +// BOTH: a first publication owed for a container whose slot may not be readable yet is retried until +// its deadline instead of being taken as nothing to send. Game thread; held actors are CachedObjRefs. + +#pragma once + +#include +#include + +namespace coop::props::container_birth { + +// How long a transfer waits for its other half: the client for the host's echo, the host for the +// author's slice, either side for a slot to become readable. Long against an echo (a round trip and +// a 250 ms sweep), short against a session. +inline constexpr uint64_t kTtlMs = 30000; +// Transfers in flight per author. Throws are human-rate, so the cap only bounds a peer that floods, +// and that peer's oldest goes, never another author's. +inline constexpr size_t kMaxPerAuthor = 64; + +// CLIENT, prop_drop_intent, at the drop intent for a container this peer threw. +void NoteAuthoredBirth(void* actor); +// HOST, prop_drop_intent, at the spawn from a client's intent: the copy is empty and awaited. +void ExpectBirthSlice(void* actor, uint8_t authorSlot); +// HOST, host_spawn_watcher, when its drain takes the host's own birth: a container with contents +// publishes them, since the mirrors are born empty. One awaited from a client says nothing. +void NoteHostBirth(void* actor); +// HOST: the transfers this slot authored end with it. +void OnPeerGone(uint8_t slot); + +// HOST: whether `actor` is an awaited transfer, from this author or (authorSlot 0) from any. +bool Awaited(void* actor, uint8_t authorSlot); +// HOST: the author's slice landed; the transfer ends. +void Complete(void* actor); + +// Per contents sweep. CLIENT: every thrown container the echo has bound goes into `dirty`, owed as a +// birth. HOST: a transfer past its deadline ends, said; a host birth with contents goes into `dirty`. +void Sweep(uint64_t nowMs, std::set& dirty); + +// CLIENT: true when this eid's next fan-out is its birth slice. +bool IsBirthSlice(uint32_t eid); + +// What an unreadable container means for its eid now: nothing owed (nothing to send), still owed +// (retry), or owed past its deadline (given up: the row is gone, and the caller says so). +enum class Owed : uint8_t { None, Pending, GivenUp }; +Owed FirstOwed(uint32_t eid, uint64_t nowMs); +// The first publication of `eid` went to every peer. +void FirstSent(uint32_t eid); +// `eid` no longer resolves: whatever was owed for it goes. +void Forget(uint32_t eid); + +void Reset(); + +} // namespace coop::props::container_birth diff --git a/src/votv-coop/include/coop/props/container_contents_sync.h b/src/votv-coop/include/coop/props/container_contents_sync.h index 5791c2514..5076c1921 100644 --- a/src/votv-coop/include/coop/props/container_contents_sync.h +++ b/src/votv-coop/include/coop/props/container_contents_sync.h @@ -51,22 +51,6 @@ void QueueConnectBroadcastForSlot(int peerSlot); // the TTL runs as a leak guard only. Called from the client-side snapshot dispatch. void NoteJoinSnapshotBracket(bool open); -// A container that changed hands through the world: its contents are in this peer's own array, -// bound by the game's loadData, and no verb fired to mark it. Game thread. -// -// CLIENT, prop_drop_intent, at the drop intent for a container it threw: once the host's echo binds -// the actor, its slice goes to the host as a birth. -void NoteAuthoredBirth(void* actor); -// HOST, prop_drop_intent, at the spawn from a client's intent: the copy is empty and a transfer in -// progress. It is not published while awaited; the author's slice is taken without a base, merged -// with whatever the host put in meanwhile, and published to every peer. -void ExpectBirthSlice(void* actor, uint8_t authorSlot); -// HOST, host_spawn_watcher, when the drain takes the host's own birth: a container with contents -// publishes them, since the mirrors are born empty. One awaited from a client says nothing. -void NoteHostBirth(void* actor); -// HOST: a transfer awaited from this slot ends with it. Game thread. -void OnPeerGone(uint8_t slot); - void OnDisconnect(); // ---- dev-instrument seams (coop/dev/container_selftest) ------------------------ diff --git a/src/votv-coop/sources.cmake b/src/votv-coop/sources.cmake index 56b5d9288..6c82b0b34 100644 --- a/src/votv-coop/sources.cmake +++ b/src/votv-coop/sources.cmake @@ -462,6 +462,7 @@ set(VOTVCOOP_SOURCES src/coop/build_trust/self_identity.cpp src/coop/items/inventory_wire.cpp src/coop/items/save_record_wire.cpp + src/coop/props/container_birth.cpp src/coop/props/container_contents_sync.cpp src/coop/props/container_park.cpp src/coop/props/container_slice_wire.cpp diff --git a/src/votv-coop/src/coop/props/container_birth.cpp b/src/votv-coop/src/coop/props/container_birth.cpp new file mode 100644 index 000000000..1e256af6b --- /dev/null +++ b/src/votv-coop/src/coop/props/container_birth.cpp @@ -0,0 +1,185 @@ +// coop/props/container_birth.cpp -- see coop/props/container_birth.h. + +#include "coop/props/container_birth.h" + +#include "coop/element/registry.h" +#include "coop/props/container_slice_wire.h" +#include "coop/props/prop_element_tracker.h" // SessionIsHost +#include "ue_wrap/actors/container_inventory.h" +#include "ue_wrap/actors/save_record.h" +#include "ue_wrap/core/cached_obj_ref.h" +#include "ue_wrap/core/log.h" + +#include +#include +#include + +namespace coop::props::container_birth { +namespace { + +namespace ci = ue_wrap::container_inventory; +namespace cw = coop::props::container_slice_wire; + +// The actor is held across ticks, so it is a CachedObjRef: a successor in the same slot at the same +// address reads as dead rather than as the thrown container. +struct Birth { + ue_wrap::CachedObjRef actor; + uint8_t authorSlot = 0; // host: the author it awaits; client: 0, this peer + uint64_t deadlineMs = 0; +}; +std::vector g_authored; // client +std::set g_birthEids; // client: bound births whose slice is owed +std::vector g_awaited; // host +std::set g_hostBirths; // host: births with contents, published by the next sweep +std::map g_owedFirst; // eid -> deadline of its first publication + +uint64_t NowMs() { + return static_cast( + std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()).count()); +} + +bool IsHost() { return coop::prop_element_tracker::SessionIsHost(); } + +void Push(std::vector& births, void* actor, uint8_t authorSlot) { + size_t mine = 0; + auto oldest = births.end(); + for (auto it = births.begin(); it != births.end(); ++it) { + if (it->authorSlot != authorSlot) continue; + if (oldest == births.end()) oldest = it; // pushed in order, so the first is the oldest + ++mine; + } + if (mine >= kMaxPerAuthor) { + UE_LOGW("container_birth: slot %u has %zu container transfers in flight -- its oldest is given up", + static_cast(authorSlot), mine); + births.erase(oldest); + } + Birth b; + b.actor.Set(actor); + b.authorSlot = authorSlot; + b.deadlineMs = NowMs() + kTtlMs; + births.push_back(std::move(b)); +} + +} // namespace + +void NoteAuthoredBirth(void* actor) { + if (IsHost() || !ci::IsContainer(actor)) return; + void* inv = ci::InventoryOf(actor); + if (!inv || !ci::IsWorldInventory(inv)) { + UE_LOGW("container_birth: CLIENT threw container %p, but its inventory %s -- its contents stay on " + "this peer only", actor, inv ? "is not a world container's" : "does not resolve"); + return; + } + Push(g_authored, actor, 0); + UE_LOGI("container_birth: CLIENT threw container %p -- its contents go to the host once bound", actor); +} + +void ExpectBirthSlice(void* actor, uint8_t authorSlot) { + if (!IsHost() || !ci::IsContainer(actor)) return; + Push(g_awaited, actor, authorSlot); +} + +void NoteHostBirth(void* actor) { + if (!IsHost() || !ci::IsContainer(actor)) return; + if (Awaited(actor, 0)) return; // built from a client's intent: its author holds the contents + void* inv = ci::InventoryOf(actor); + if (!inv || !ci::IsWorldInventory(inv)) return; + const auto eid = coop::element::Registry::Get().EidForActor(actor); + if (eid == coop::element::kInvalidId) return; + std::vector recs; + const bool readable = ci::ReadContents(inv, recs, cw::kMaxRecords); + if (readable && recs.empty()) return; // the mirrors are born empty too + // Not readable yet is not empty: the publication is owed and retried until it is. + if (!readable) g_owedFirst[static_cast(eid)] = NowMs() + kTtlMs; + g_hostBirths.insert(static_cast(eid)); +} + +void OnPeerGone(uint8_t slot) { + // A transfer is bound to the peer that threw: a later occupant of the slot does not complete it. + for (size_t i = 0; i < g_awaited.size();) { + if (g_awaited[i].authorSlot != slot) { ++i; continue; } + UE_LOGW("container_birth: slot %u left before the contents of a container it threw came", + static_cast(slot)); + g_awaited.erase(g_awaited.begin() + static_cast(i)); + } +} + +// The host never publishes an awaited container, so nothing a slice could have been based on exists. +bool Awaited(void* actor, uint8_t authorSlot) { + if (g_awaited.empty() || !actor) return false; + const uint64_t now = NowMs(); + for (const Birth& b : g_awaited) + if (b.actor.Is(actor) && (authorSlot == 0 || b.authorSlot == authorSlot) && now < b.deadlineMs) + return true; + return false; +} + +void Complete(void* actor) { + for (size_t i = 0; i < g_awaited.size(); ++i) { + if (!g_awaited[i].actor.Is(actor)) continue; + g_awaited.erase(g_awaited.begin() + static_cast(i)); + return; + } +} + +void Sweep(uint64_t nowMs, std::set& dirty) { + for (size_t i = 0; i < g_authored.size();) { + const Birth& b = g_authored[i]; + if (!b.actor.Alive() || nowMs >= b.deadlineMs) { + g_authored.erase(g_authored.begin() + static_cast(i)); + continue; + } + const auto eid = coop::element::Registry::Get().EidForActor(b.actor.Get()); + if (eid == coop::element::kInvalidId) { ++i; continue; } + g_birthEids.insert(static_cast(eid)); + g_owedFirst[static_cast(eid)] = b.deadlineMs; + dirty.insert(static_cast(eid)); + g_authored.erase(g_authored.begin() + static_cast(i)); + } + for (size_t i = 0; i < g_awaited.size();) { + const Birth& b = g_awaited[i]; + const bool live = b.actor.Alive(); + if (live && nowMs < b.deadlineMs) { ++i; continue; } + if (live) + UE_LOGW("container_birth: the contents of a container slot %u threw never came -- the host's " + "copy stays as it built it, and the thrower's own contents are refused from now on and " + "lost to it", static_cast(b.authorSlot)); + g_awaited.erase(g_awaited.begin() + static_cast(i)); + } + if (!g_hostBirths.empty()) { + dirty.insert(g_hostBirths.begin(), g_hostBirths.end()); + g_hostBirths.clear(); + } +} + +bool IsBirthSlice(uint32_t eid) { return g_birthEids.count(eid) != 0; } + +Owed FirstOwed(uint32_t eid, uint64_t nowMs) { + auto it = g_owedFirst.find(eid); + if (it == g_owedFirst.end()) return Owed::None; + if (nowMs < it->second) return Owed::Pending; + g_owedFirst.erase(it); + g_birthEids.erase(eid); + return Owed::GivenUp; +} + +void FirstSent(uint32_t eid) { + g_owedFirst.erase(eid); + g_birthEids.erase(eid); +} + +void Forget(uint32_t eid) { + g_owedFirst.erase(eid); + g_birthEids.erase(eid); +} + +void Reset() { + g_authored.clear(); + g_birthEids.clear(); + g_awaited.clear(); + g_hostBirths.clear(); + g_owedFirst.clear(); +} + +} // namespace coop::props::container_birth diff --git a/src/votv-coop/src/coop/props/container_contents_sync.cpp b/src/votv-coop/src/coop/props/container_contents_sync.cpp index a3e44b930..6fee6b07e 100644 --- a/src/votv-coop/src/coop/props/container_contents_sync.cpp +++ b/src/votv-coop/src/coop/props/container_contents_sync.cpp @@ -6,6 +6,7 @@ #include "coop/props/prop_save_data.h" #include "coop/props/container_contents_sync.h" +#include "coop/props/container_birth.h" #include "coop/props/container_park.h" #include "coop/props/container_slice_wire.h" #include "coop/props/container_write_policy.h" @@ -16,7 +17,6 @@ #include "coop/net/session.h" #include "coop/session/net_pump.h" #include "ue_wrap/actors/container_inventory.h" -#include "ue_wrap/core/cached_obj_ref.h" #include "ue_wrap/actors/save_record.h" #include "ue_wrap/core/log.h" #include "ue_wrap/core/reflection.h" @@ -41,6 +41,7 @@ namespace wp = coop::props::container_write_policy; namespace pk = coop::props::container_park; namespace cw = coop::props::container_slice_wire; namespace ci = ue_wrap::container_inventory; +namespace cb = coop::props::container_birth; using coop::element::LivePropActor; @@ -92,28 +93,6 @@ std::set g_retry; // it too, harmlessly; the drain's own gates filter those. std::atomic g_takeObjInFlight{false}; -// Births: a container thrown into the world, whose contents no verb marked. A client holds the actor -// it threw until the host's echo binds it, then ships its slice as a birth. The host's copy, built -// empty from the intent, is a transfer in progress until the author's slice lands: it is not -// published, and every slice of that author for it is the newest version of the transfer. Both -// sides expire, so a lost intent leaves nothing behind. -constexpr uint64_t kBirthTtlMs = 30000; -constexpr size_t kMaxBirths = 64; // per author: one peer's throws never evict another's -// The actor is held across ticks, so it is a CachedObjRef: a successor in the same slot at the same -// address reads as dead rather than as the thrown container. -struct Birth { - ue_wrap::CachedObjRef actor; - uint8_t authorSlot = 0; // host: the author it awaits - uint64_t deadlineMs = 0; -}; -std::vector g_authoredBirths; // client -std::set g_birthEids; // client: bound births whose slice is owed -std::vector g_awaitedBirths; // host -// A first publication owed for an eid -- a client's birth, the host's own thrown container -- whose -// slot may not be readable yet: an unreadable read is retried until the deadline instead of being -// taken as nothing to send. -std::map g_owedFirst; - uint64_t NowMs() { return static_cast( std::chrono::duration_cast( @@ -154,11 +133,11 @@ bool BroadcastContainer(coop::net::Session* s, uint32_t eid, void* inv, int toSl if (!ReadContents(inv, recs)) { // Nothing resolvable is not a transport failure -- unless a first publication is owed for this // eid, which a slot not readable yet would otherwise lose for good. - auto it = g_owedFirst.find(eid); - if (it == g_owedFirst.end()) return true; - if (NowMs() < it->second) return false; - g_owedFirst.erase(it); - g_birthEids.erase(eid); + switch (cb::FirstOwed(eid, NowMs())) { + case cb::Owed::None: return true; + case cb::Owed::Pending: return false; + case cb::Owed::GivenUp: break; + } UE_LOGW("container_contents: eid=%u -- its contents never became readable; the first " "publication is given up", eid); return true; @@ -166,7 +145,7 @@ bool BroadcastContainer(coop::net::Session* s, uint32_t eid, void* inv, int toSl // The base being edited from: for a client the last host truth it applied; the host authors // from its own state and sends 0. uint64_t baseHash = 0; - const bool birth = !IsHost() && toSlot < 0 && g_birthEids.count(eid) != 0; + const bool birth = !IsHost() && toSlot < 0 && cb::IsBirthSlice(eid); if (birth) { baseHash = cw::kBirthBase; } else if (!IsHost()) { @@ -190,8 +169,7 @@ bool BroadcastContainer(coop::net::Session* s, uint32_t eid, void* inv, int toSl : coop::blob_chunks::SendBlobToSlot(s, toSlot, coop::net::ReliableKind::ContainerContents, g_nextSeq++, blob); if (ok) { - if (birth) g_birthEids.erase(eid); - if (toSlot < 0) g_owedFirst.erase(eid); + if (toSlot < 0) cb::FirstSent(eid); if (toSlot < 0) g_sentHash[eid] = h; // only a FAN-OUT establishes what every peer has // A client's own accepted slice IS the host's next published truth, and the author is // deliberately excluded from the relay that carries it -- so it advances its base here. @@ -239,79 +217,6 @@ void RelayToOthers(coop::net::Session* s, uint8_t authorSlot, const std::vector< } } -// The awaited birth for this eid, from this author or (authorSlot 0) from any, or null. The host never -// publishes an awaited container, so nothing a slice could have been based on exists yet. -Birth* AwaitedBirth(uint32_t eid, uint8_t authorSlot) { - if (g_awaitedBirths.empty()) return nullptr; - void* actor = LivePropActor(eid); - if (!actor) return nullptr; - const uint64_t now = NowMs(); - for (Birth& b : g_awaitedBirths) - if (b.actor.Is(actor) && (authorSlot == 0 || b.authorSlot == authorSlot) && now < b.deadlineMs) - return &b; - return nullptr; -} - -void DropAwaitedBirth(uint32_t eid) { - void* actor = LivePropActor(eid); - if (!actor) return; - for (size_t i = 0; i < g_awaitedBirths.size(); ++i) { - if (!g_awaitedBirths[i].actor.Is(actor)) continue; - g_awaitedBirths.erase(g_awaitedBirths.begin() + static_cast(i)); - return; - } -} - -// One more birth, that author's oldest given up (and said) past the cap: throws are human-rate, so -// the cap only bounds a peer that floods, and only that peer pays for it. -void PushBirth(std::vector& births, void* actor, uint8_t authorSlot) { - size_t mine = 0; - auto oldest = births.end(); - for (auto it = births.begin(); it != births.end(); ++it) { - if (it->authorSlot != authorSlot) continue; - if (oldest == births.end()) oldest = it; // pushed in order, so the first is the oldest - ++mine; - } - if (mine >= kMaxBirths) { - UE_LOGW("container_contents: slot %u has %zu container transfers in flight -- its oldest is " - "given up", static_cast(authorSlot), mine); - births.erase(oldest); - } - Birth b; - b.actor.Set(actor); - b.authorSlot = authorSlot; - b.deadlineMs = NowMs() + kBirthTtlMs; - births.push_back(std::move(b)); -} - -// Client: a thrown container the host's echo has bound is marked, its slice owed as a birth. Host: -// an awaited birth that never came is dropped, and said. -void SweepBirths(uint64_t now) { - for (size_t i = 0; i < g_authoredBirths.size();) { - const Birth& b = g_authoredBirths[i]; - if (!b.actor.Alive() || now >= b.deadlineMs) { - g_authoredBirths.erase(g_authoredBirths.begin() + static_cast(i)); - continue; - } - const auto eid = coop::element::Registry::Get().EidForActor(b.actor.Get()); - if (eid == coop::element::kInvalidId) { ++i; continue; } - g_birthEids.insert(static_cast(eid)); - g_owedFirst[static_cast(eid)] = b.deadlineMs; - g_dirty.insert(static_cast(eid)); - g_authoredBirths.erase(g_authoredBirths.begin() + static_cast(i)); - } - for (size_t i = 0; i < g_awaitedBirths.size();) { - const Birth& b = g_awaitedBirths[i]; - const bool live = b.actor.Alive(); - if (live && now < b.deadlineMs) { ++i; continue; } - if (live) - UE_LOGW("container_contents: the contents of a container slot %u threw never came -- the " - "host's copy stays as it built it, and the thrower's own contents are refused from now " - "on and lost to it", static_cast(b.authorSlot)); - g_awaitedBirths.erase(g_awaitedBirths.begin() + static_cast(i)); - } -} - // The dirty drain. void DrainDirty(coop::net::Session* s) { @@ -331,8 +236,7 @@ void DrainDirty(coop::net::Session* s) { // resolving. void* actor = LivePropActor(eid); if (!actor) { // gone: nothing is owed for it any more - g_owedFirst.erase(eid); - g_birthEids.erase(eid); + cb::Forget(eid); continue; } if (!ci::IsContainer(actor)) continue; @@ -340,18 +244,16 @@ void DrainDirty(coop::net::Session* s) { if (!inv || !ci::IsWorldInventory(inv)) { // BOUNDARY 1 (fail-closed) // A first publication owed for it waits for the component instead of ending here, and // is given up, said, at its deadline. - auto owed = g_owedFirst.find(eid); - if (owed == g_owedFirst.end()) continue; - if (NowMs() < owed->second) { g_retry.insert(eid); continue; } - g_owedFirst.erase(owed); - g_birthEids.erase(eid); + const cb::Owed owed = cb::FirstOwed(eid, NowMs()); + if (owed == cb::Owed::None) continue; + if (owed == cb::Owed::Pending) { g_retry.insert(eid); continue; } UE_LOGW("container_contents: eid=%u -- its inventory never resolved; the first publication " "is given up", eid); continue; } // A transfer in progress is not published: what the host added to it so far merges into the // author's slice when it lands, and is published with it. - if (IsHost() && AwaitedBirth(eid, 0)) { g_retry.insert(eid); continue; } + if (IsHost() && cb::Awaited(actor, 0)) { g_retry.insert(eid); continue; } if (!BroadcastContainer(s, eid, inv, -1, /*force=*/false)) g_retry.insert(eid); } } @@ -437,7 +339,8 @@ Ingest ParseAndApply(const std::vector& blob, uint32_t& outEid, uint8_t // nothing for it, so any slice of that author is the newest version of the transfer, whatever // base it names -- an edit made while the first slice waited in the pen included. There is no // reach to measure either: the throw put it where it is. - const bool birth = IsHost() && senderSlot != 0 && AwaitedBirth(outEid, senderSlot) != nullptr; + void* const bornActor = (IsHost() && senderSlot != 0) ? LivePropActor(outEid) : nullptr; + const bool birth = bornActor && cb::Awaited(bornActor, senderSlot); // Unjudged is not unbounded: a birth slice spends the same arrival budget an edit does, so an // author cannot make the host parse slices for this container at any rate while it waits. Past // the budget it is HELD in the pen rather than refused: it is the thrower's only copy of the @@ -529,7 +432,7 @@ Ingest ParseAndApply(const std::vector& blob, uint32_t& outEid, uint8_t // moved on, and the second peer to edit a container was refused for being as up to date as the // first. if (outcome == Ingest::Applied && IsHost() && senderSlot != 0) { - if (birth) DropAwaitedBirth(outEid); + if (birth) cb::Complete(bornActor); if (merged) { // The author lacks the host's part: the merged contents go to every peer, the author // included, by the host's own fan-out on the next sweep. @@ -660,7 +563,7 @@ void Tick() { // the host, which arbitrates and relays. Both sweep their parked inbound blobs -- the host's // were never swept at all, so a client write it could not resolve on arrival was neither // retried nor evicted for the life of the session. - SweepBirths(now); + cb::Sweep(now, g_dirty); DrainDirty(s); pk::Sweep(&ReplayParked, NowMs()); } @@ -692,49 +595,6 @@ void OnContentsChunk(const coop::net::BlobChunkPayload& p, uint8_t senderSlot) { } } -void NoteAuthoredBirth(void* actor) { - if (IsHost() || !ci::IsContainer(actor)) return; - void* inv = ci::InventoryOf(actor); - if (!inv || !ci::IsWorldInventory(inv)) { - UE_LOGW("container_contents: CLIENT threw container %p, but its inventory %s -- its contents " - "stay on this peer only", actor, inv ? "is not a world container's" : "does not resolve"); - return; - } - PushBirth(g_authoredBirths, actor, 0); - UE_LOGI("container_contents: CLIENT threw container %p -- its contents go to the host once bound", actor); -} - -void ExpectBirthSlice(void* actor, uint8_t authorSlot) { - if (!IsHost() || !ci::IsContainer(actor)) return; - PushBirth(g_awaitedBirths, actor, authorSlot); -} - -void NoteHostBirth(void* actor) { - if (!IsHost() || !ci::IsContainer(actor)) return; - for (const Birth& b : g_awaitedBirths) - if (b.actor.Is(actor)) return; // built from a client's intent: its author holds the contents - void* inv = ci::InventoryOf(actor); - if (!inv || !ci::IsWorldInventory(inv)) return; - const auto eid = coop::element::Registry::Get().EidForActor(actor); - if (eid == coop::element::kInvalidId) return; - std::vector recs; - const bool readable = ReadContents(inv, recs); - if (readable && recs.empty()) return; // the mirrors are born empty too - // Not readable yet is not empty: the publication is owed and retried until it is. - if (!readable) g_owedFirst[static_cast(eid)] = NowMs() + kBirthTtlMs; - g_dirty.insert(static_cast(eid)); -} - -void OnPeerGone(uint8_t slot) { - // A transfer is bound to the peer that threw: a later occupant of the slot does not complete it. - for (size_t i = 0; i < g_awaitedBirths.size();) { - if (g_awaitedBirths[i].authorSlot != slot) { ++i; continue; } - UE_LOGW("container_contents: slot %u left before the contents of a container it threw came", - static_cast(slot)); - g_awaitedBirths.erase(g_awaitedBirths.begin() + static_cast(i)); - } -} - void QueueConnectBroadcastForSlot(int peerSlot) { auto* s = g_session.load(std::memory_order_acquire); if (!s || !IsHost()) return; @@ -749,7 +609,7 @@ void QueueConnectBroadcastForSlot(int peerSlot) { void* inv = ci::InventoryOf(pr.actor); if (!inv || !ci::IsWorldInventory(inv)) continue; // BOUNDARY 1 (fail-closed) // A transfer in progress reaches the joiner with the fan-out that completes it. - if (AwaitedBirth(static_cast(pr.id), 0)) continue; + if (cb::Awaited(pr.actor, 0)) continue; if (BroadcastContainer(s, static_cast(pr.id), inv, peerSlot, /*force=*/true)) ++sent; } UE_LOGI("container_contents: connect seed -> slot %d: %zu world containers", peerSlot, sent); @@ -796,10 +656,7 @@ void OnDisconnect() { g_sentHash.clear(); g_baseHash.clear(); g_appliedHash.clear(); - g_authoredBirths.clear(); - g_birthEids.clear(); - g_awaitedBirths.clear(); - g_owedFirst.clear(); + cb::Reset(); g_asm.Clear(); g_nextSweep = 0; g_announced = false; diff --git a/src/votv-coop/src/coop/props/host_spawn_watcher.cpp b/src/votv-coop/src/coop/props/host_spawn_watcher.cpp index 79863c34b..8290ce8b7 100644 --- a/src/votv-coop/src/coop/props/host_spawn_watcher.cpp +++ b/src/votv-coop/src/coop/props/host_spawn_watcher.cpp @@ -11,7 +11,7 @@ #include "coop/props/prop_element_tracker.h" #include "coop/props/prop_lifecycle.h" // ExpressSpawnedProp (reuse the keyed broadcast) #include "coop/props/prop_save_data.h" // PublishHostBirth (the record behind the express) -#include "coop/props/container_contents_sync.h" // NoteHostBirth (a container's contents behind it) +#include "coop/props/container_birth.h" // NoteHostBirth (a container's contents behind it) #include "coop/props/prop_drive_host.h" // Coast (a falling birth's pose until it rests) #include "coop/props/prop_wire_parity.h" // PhysFlagsOf (frozen, static and sleep) #include "coop/props/remote_prop_spawn.h" @@ -418,7 +418,7 @@ void DrainPendingSpawns(coop::net::Session* s) { // Tracked since the enqueue (the init seam expressed it first): someone owns its row, and // only its fall and a container's contents are left to give it. if (coop::element::Registry::Get().EidForActor(e.actor) != coop::element::kInvalidId) { - coop::props::container_contents_sync::NoteHostBirth(e.actor); + coop::props::container_birth::NoteHostBirth(e.actor); CoastIfFalling(e.actor); continue; } @@ -439,7 +439,7 @@ void DrainPendingSpawns(coop::net::Session* s) { // host ejected read EMPTIED on the client and INTACT on the host. coop::prop_save_data::PublishHostBirth(s, e.actor); // A container's contents are not in that record: its own lane publishes them. - coop::props::container_contents_sync::NoteHostBirth(e.actor); + coop::props::container_birth::NoteHostBirth(e.actor); CoastIfFalling(e.actor); } } diff --git a/src/votv-coop/src/coop/props/prop_drop_intent.cpp b/src/votv-coop/src/coop/props/prop_drop_intent.cpp index 8ad0bc4e9..2c32c10a7 100644 --- a/src/votv-coop/src/coop/props/prop_drop_intent.cpp +++ b/src/votv-coop/src/coop/props/prop_drop_intent.cpp @@ -14,7 +14,8 @@ #include "coop/props/prop_spawn_authoring.h" // BirthIsPlayerAuthored (the spawn menu / the toolgun) #include "coop/props/prop_wire_parity.h" // PhysFlagsOf, the one builder of a live prop's flags #include "coop/props/prop_element_tracker.h"// GetPropElementIdForActor, ResolveLiveActorByKey -#include "coop/props/container_contents_sync.h" // TakeObjInFlight; a thrown container's birth slice +#include "coop/props/container_contents_sync.h" // TakeObjInFlight -- mark a container-extraction birth +#include "coop/props/container_birth.h" // a thrown container's birth slice #include "coop/session/world_load_episode.h" // InEpisode (quiet during the join loadObjects churn) #include "ue_wrap/core/call.h" // ParamFrame + Call (setKey on the host re-spawn) #include "ue_wrap/engine/engine.h" // BeginDeferredSpawn/FinishDeferredSpawn/SetActorScale3D @@ -319,7 +320,7 @@ void* HostSpawnPlacedProp(const coop::net::PropDropIntentPayload& p, const std:: if (!coop::prop_save_data::ApplyParked(actor, key)) coop::prop_save_data::ExpectRecordFor(actor, key, authorSlot); // A container's contents are not in its record: they come as the author's birth slice. - coop::props::container_contents_sync::ExpectBirthSlice(actor, authorSlot); + coop::props::container_birth::ExpectBirthSlice(actor, authorSlot); return actor; } @@ -494,7 +495,7 @@ void Tick(coop::net::Session* session) { // tape, an empty disc -- and broadcast that as truth. coop::prop_save_data::Publish(session, e.actor, key); // And a container's contents, which its record does not carry, once the host's echo binds it. - coop::props::container_contents_sync::NoteAuthoredBirth(e.actor); + coop::props::container_birth::NoteAuthoredBirth(e.actor); if (parked) UnparkKey(key); // consume from BOTH the set AND the FIFO (mirror invariant) // The claim, for every entry that was not claimed at the seam: this prop is now named on // the wire, so the join sweep must not doom it while the host's answer is in flight. A diff --git a/src/votv-coop/src/coop/session/subsystems.cpp b/src/votv-coop/src/coop/session/subsystems.cpp index 0737d8121..db5c06ecb 100644 --- a/src/votv-coop/src/coop/session/subsystems.cpp +++ b/src/votv-coop/src/coop/session/subsystems.cpp @@ -37,6 +37,7 @@ #include "coop/interactables/mirror_slot_entry.h" #include "coop/interactables/floppy_slot_sync.h" #include "coop/interactables/floppybox_sync.h" // the disc crate LIFO lane +#include "coop/props/container_birth.h" #include "coop/props/container_contents_sync.h" // the world-container GObjStack slice #include "coop/props/container_view_close.h" // a view into a container ends where the player's reach does #include "coop/props/prop_food_state.h" // the food family's live state and who authors it @@ -431,7 +432,7 @@ void DisconnectSlot(coop::net::Session& session, int slot) { coop::dish_hashcode_sync::OnPeerGone(static_cast(slot)); coop::sat_console_sync::OnPeerGone(static_cast(slot)); // its terminal kept, running its command, for its return coop::daily_task_sync::OnPeerGone(static_cast(slot)); // a departed slot is owed no task - coop::props::container_contents_sync::OnPeerGone(static_cast(slot)); // a container transfer it had in flight + coop::props::container_birth::OnPeerGone(static_cast(slot)); // a container transfer it had in flight coop::signal_sync::OnDisconnectSlot(slot); coop::email_sync::OnDisconnectSlot(slot); // Shut the chat lane's per-slot seed gate: the next occupant's applied range starts empty, so From 78f390208a74db112f210e08d00a5ed2f596a564 Mon Sep 17 00:00:00 2001 From: pelmentor Date: Sat, 10 Oct 2026 02:40:43 +0600 Subject: [PATCH 043/306] [coop] the ignite watch's comment names no local-only path It cited a tool that is not in the public tree; it now names what that tool read, the player's control-flow graph and its offset listing. Co-Authored-By: Claude Opus 5.5 --- src/votv-coop/src/coop/player/player_damage.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/votv-coop/src/coop/player/player_damage.cpp b/src/votv-coop/src/coop/player/player_damage.cpp index a1bada1dc..7a9e9b73c 100644 --- a/src/votv-coop/src/coop/player/player_damage.cpp +++ b/src/votv-coop/src/coop/player/player_damage.cpp @@ -115,8 +115,8 @@ ue_wrap::script_gate::Verdict OnDamageVerbPre(const ue_wrap::script_gate::Call& // health write and nothing else, so ignite is refused on a peer's puppet: the owner computes its own // fire as it computes its own damage. It is the one way in. attemptIgnite, the verb a spreading fire // calls, is an empty stub on the player (its ubergraph entry is a bare pop), and startBurning is -// private and reached from ignite alone (tools/bp_cfg.py mainPlayer --fn attemptIgnite; the -// mainPlayer offsets listing). Both refusals ask IsPuppet rather than "not the local pawn", the +// private and reached from ignite alone (mainPlayer_C's control-flow graph and its offset +// listing). Both refusals ask IsPuppet rather than "not the local pawn", the // impact cancel's test, because a stale local pawn across a respawn must never make this machine's // own player immune. constexpr const wchar_t* kIgniteName = L"ignite"; From 9926484f9041a78a74093d6b129820ee8c156c78 Mon Sep 17 00:00:00 2001 From: Shinobu2 Date: Tue, 6 Oct 2026 14:16:25 -0700 Subject: [PATCH 044/306] [coop] the story-event creatures are host mirrors on a client The eventer, the gray controller, the super egger, the balls follower and the night-egg ticker mint their creatures inside their own graphs, where no spawn interceptor sees them, so the vent crawler, the grays, the eggs and the tentacle balls never mirrored and a client's egg ticker rolled its own. They are catch sources now, and the three creature classes join the NPC allowlist. On a client the creatures' BeginPlay, ticks, overlaps and AI verbs are refused at the script gate, and so are the spawners' mint bodies and the egg ticker, so a client mints none of its own. The net of the commits below without the late allowlist; the eventer's own verbs are left to the event lane. Co-Authored-By: Devin SWE-2 (cherry picked from commit 3cc2e47ae450fc56b54467f8dfacff77fbb272ab) (cherry picked from commit 1b260cf7f3402459d98b1d632b3d13a044fc8513) (cherry picked from commit b359b9d43694d8b165b7aa47929459ccdfc381af) (cherry picked from commit 3bd72986853ec758e78fe73223fcbcafbaedeee8) --- docs/npcs-and-kerfur.md | 3 + .../include/coop/creatures/npc_world_enum.h | 6 +- .../include/coop/world/spawn_authority.h | 7 +- .../include/ue_wrap/core/sdk_profile_names.h | 10 +++ .../src/coop/creatures/npc_world_enum.cpp | 51 ++++++++--- .../src/coop/world/spawn_authority.cpp | 86 ++++++++++++++++++- 6 files changed, 144 insertions(+), 19 deletions(-) diff --git a/docs/npcs-and-kerfur.md b/docs/npcs-and-kerfur.md index 04090609d..981fd646b 100644 --- a/docs/npcs-and-kerfur.md +++ b/docs/npcs-and-kerfur.md @@ -60,7 +60,10 @@ leaving one leaves nothing to restore. | mushroom master and spawner | spawn refused | they spawn at their own world transform | | yellow wisp spawner | tick refused | anchors on a random navmesh point; the product is host-mirrored | | sky wisp spawner | tick refused | absolute map coordinates, not player-anchored | +| night egg spawner | tick refused | a weighted night roll mints one egg at random absolute map coordinates; the host's egg mirrors through the source-gated catch | | jellyfish path | spawn refused | its seven fish are made inside its graph; the host's run is mirrored through the source-gated catch | +| vent crawler, grays, eggs, tentacle balls | gameplay entries refused (BeginPlay, tick, overlaps, AI and despawn verbs); animation and pose stay | on a client they exist only as host mirrors through the npc lane's allowlist | +| gray controller, balls follower, super egger | the mint and activation bodies refused | their creature BeginDeferred calls are bytecode-internal: no spawn interceptor can see them, so the bodies must not run | | firefly, pinecone | left running | anchored on the local player's camera: each peer owns its roll and the others render a mirror | | eyer | left running | it stalks its own peer's player: each peer owns its eyer and the others render a mirror (`coop/creatures/owner_entity_sync`) | | underground garbage | left running | per-peer loot mounds; refusing one would delete a client's loot with no host replacement | diff --git a/src/votv-coop/include/coop/creatures/npc_world_enum.h b/src/votv-coop/include/coop/creatures/npc_world_enum.h index 7df2f70ad..a817d4d6e 100644 --- a/src/votv-coop/include/coop/creatures/npc_world_enum.h +++ b/src/votv-coop/include/coop/creatures/npc_world_enum.h @@ -52,8 +52,10 @@ int RegisterExistingWorldNpcs(NpcEnumOrigin origin); // (FFrame::Object) to the spawners whose output is host-authoritative and mirrored: the // wisp event swarm, the piramid chain, the ambient sky-wisp ticker -- which anchors at // absolute map coordinates rather than around a player, so the host rolls it and the -// client's own ticker is cancelled in the spawn authority -- and the sell gun's coin mint, -// whose deferred spawn is bytecode-internal and invisible to the world-actor interceptor. +// client's own ticker is cancelled in the spawn authority -- the sell gun's coin mint, +// whose deferred spawn is bytecode-internal and invisible to the world-actor interceptor, +// and the story-event spawners (the eventer, the gray controller, the super egger and the +// balls follower) whose event-creature output mints inside their own graphs. // The catch fires PRE-Finish, with the transform still unset, which is why it only QUEUES; // the drain enrolls next pump tick, when FinishSpawningActor has run and the transform is // real. Called by npc_sync::Install once the UFunction and the allowlist resolve and the diff --git a/src/votv-coop/include/coop/world/spawn_authority.h b/src/votv-coop/include/coop/world/spawn_authority.h index 07f00ad26..e5617290a 100644 --- a/src/votv-coop/include/coop/world/spawn_authority.h +++ b/src/votv-coop/include/coop/world/spawn_authority.h @@ -7,8 +7,11 @@ // it is not shared: a spawner whose product has no mirror yet runs on every peer, each rolling its // own. The deer, bp7 and hexahive spawners, measured rolling on a connected client, are such. -// ONE table: each row is a body a client must not run, a spawner's entry function or its tick, -// refused at the script gate by a watch keyed on the row's class and function names. So a row holds +// ONE table: each row is a body a client must not run, a spawner's entry function or its tick -- +// and, for the story-event creatures, the AI, timer and contact bodies of an actor that on a +// client exists only as a host mirror, plus the mint bodies of the event spawners whose output +// mints bytecode-internally where no spawn interceptor can see it -- refused at the script gate +// by a watch keyed on the row's class and function names. So a row holds // for every instance, a late one included, on every dispatch route of the game thread (the gate fires // there, and every spawner body runs there), and for a class the game re-creates with each world; the // refusal is gated on an active client session, so the host and solo play run every body and a diff --git a/src/votv-coop/include/ue_wrap/core/sdk_profile_names.h b/src/votv-coop/include/ue_wrap/core/sdk_profile_names.h index 4546e068e..b0cde5833 100644 --- a/src/votv-coop/include/ue_wrap/core/sdk_profile_names.h +++ b/src/votv-coop/include/ue_wrap/core/sdk_profile_names.h @@ -72,6 +72,13 @@ inline constexpr const wchar_t* NpcClass_AriralPigBeater = L"npc_ariral_pigBeate // crawler's wall crawl is a known yaw-only gap. inline constexpr const wchar_t* NpcClass_KillerWisp = L"killerwisp_C"; inline constexpr const wchar_t* NpcClass_VentCrawler = L"ventCrawler_C"; +// The other story-event creatures. Their packages are hard imports of the eventer and of the +// level-placed gray controller and balls follower, so they load with the level, as ventCrawler_C +// does. grayboar_C stays out: its SuperStruct is prop_C, not Character (the boar-war prop is the +// prop lane's); only the grayboarPawn_C helper it mints is a Character. +inline constexpr const wchar_t* NpcClass_GrayTest = L"grayTest_C"; // graysforest pack +inline constexpr const wchar_t* NpcClass_Eg = L"eg_C"; // eggvasion and night eggs +inline constexpr const wchar_t* NpcClass_TentacleBall = L"tentacleBall_C"; // tentacleBalls pack // wisp_C (Awisp_C : ACharacter): the wispSwarm-event wisp and the ambient sky wisp // (ticker_wispSpawner_C spawns at absolute map coordinates, world-anchored, so host-authoritative // too). Both spawn paths are EX_CallMath, caught by the ufunction_hook Func thunk on @@ -107,6 +114,9 @@ inline constexpr const wchar_t* kNpcAllowlist[] = { NpcClass_AriralPigBeater, NpcClass_KillerWisp, // yellow Killer Wisp (late-game; AIPerception like the 12) NpcClass_VentCrawler, // the ventCrawler event (its wall crawl is a yaw-only gap) + NpcClass_GrayTest, + NpcClass_Eg, + NpcClass_TentacleBall, NpcClass_Wisp, // wispSwarm-event and ambient sky wisp (both host-authoritative) NpcClass_WispB, // sky-wisp color variants (ticker_wispSpawner map; direct ACharacter NpcClass_WispRed, // subclasses, NOT wisp_C children -- each needs its own row) diff --git a/src/votv-coop/src/coop/creatures/npc_world_enum.cpp b/src/votv-coop/src/coop/creatures/npc_world_enum.cpp index bd98b5081..5de8e0e9e 100644 --- a/src/votv-coop/src/coop/creatures/npc_world_enum.cpp +++ b/src/votv-coop/src/coop/creatures/npc_world_enum.cpp @@ -124,18 +124,30 @@ coop::element::ElementId EnrollUntrackedNpcActor(void* obj, const std::wstring& // chain (four killer wisps on the NPC lane and one pyramid on the world-actor lane, a spawn // the interceptor never sees); the ambient sky-wisp ticker and its colour variants (it // anchors at absolute map coordinates, not around a player, so the host rolls them and the -// client's ticker is cancelled in the spawn authority); and the sell gun's coin mint, whose -// deferred spawn inside the sell verb is bytecode-internal on the host's own sale and on the -// re-commit of a client's sale alike, so the world-actor lane's interceptor never sees it and -// without this row the coin allowlist entry is inert; and the jellyfish path, whose spawn makes -// its seven fish inside its own graph. A future creature event adds its trigger class here once -// its target class joins the NPC allowlist. +// client's ticker is cancelled in the spawn authority); the night-egg ticker, the same +// world-anchored shape (its tick's weighted roll mints one eg_C at random absolute map coordinates and +// writes gamemode->eg, and the client's own tick is refused in the spawn authority); and the +// sell gun's coin mint, whose deferred spawn inside the sell verb is bytecode-internal on the +// host's own sale and on the re-commit of a client's sale alike, so the world-actor lane's +// interceptor never sees it and without this row the coin allowlist entry is inert; and the +// jellyfish path, whose spawn makes its seven fish inside its own graph. The four story-event +// spawners below close the same gap for the event creatures: every one of their BeginDeferred +// sites is bytecode-internal (a CallFunc inside the ubergraph, never ProcessEvent-dispatched), +// so without the row the creature mirrors never exist. Their outputs still pass the allowlist +// gates in the queue branch below (NPC, else world-actor), so no arbitrary EX +// spawn enrolls. constexpr const wchar_t* kExSpawnSourceClasses[] = { - L"trigger_wispSwarm_C", // the wisps event swarm - L"piramidSpawner_C", // the piramid event chain - L"ticker_wispSpawner_C", // the ambient sky wisps - L"prop_coingun_C", // the sell gun's coin mint - L"jellyfishPath_C", // the space jellyfish's seven, on the world-actor lane + L"trigger_wispSwarm_C", // the wisps event swarm + L"piramidSpawner_C", // the piramid event chain + L"ticker_wispSpawner_C", // the ambient sky wisps + L"ticker_egSpawner_C", // the ambient night eggs (one eg_C per successful roll) + L"prop_coingun_C", // the sell gun's coin mint + L"jellyfishPath_C", // the space jellyfish's seven, on the world-actor lane + L"trigger_eventer_C", // the scheduler's dispatcher: ventCrawler_C, superEgger_C, + // the ufoDropper set and the other already-allowlisted outputs + L"grayEventController_C", // the graysforest brain's spawn() mints the grayTest_C pack + L"superEgger_C", // the egger's spwn() mints the eggvasion eg_C children + L"tentacleBallsFollower_C", // the follower's runTrigger() mints the tentacleBall_C pack }; // A source's output may be a world-actor-lane class (the pyramid): the same catch seam, @@ -189,7 +201,18 @@ void OnBeginDeferredExSpawn(void* /*context*/, void* srcObj, void* spawned) { if (!coop::npc_sync::IsAllowlistedClass(cls) && !IsWaAllowlistedClass(cls)) return; const int32_t idx = R::InternalIndexOf(spawned); std::lock_guard lk(g_pendingMx); - if (g_pendingExSpawns.size() >= kMaxPendingExSpawns) return; // drain stalled? never grow unbounded + if (g_pendingExSpawns.size() >= kMaxPendingExSpawns) { + // The cap exists so a stalled drain cannot grow the queue unbounded; a full queue then + // drops every later catch quietly. Said once: the drop is per-catch, the evidence once. + static bool s_overflowSaid = false; + if (!s_overflowSaid) { + s_overflowSaid = true; + UE_LOGW("npc-sync[ex-spawn]: pending queue FULL at %zu -- dropping catches until the " + "drain runs (a full queue mirrors nothing more this burst)", + g_pendingExSpawns.size()); + } + return; + } g_pendingExSpawns.push_back({spawned, idx}); } @@ -202,7 +225,9 @@ void InstallExSpawnCatch(void* beginDeferredFn) { if (ue_wrap::ufunction_hook::InstallPostHook(beginDeferredFn, &OnBeginDeferredExSpawn)) { UE_LOGI("npc-sync[ex-spawn]: Func-thunk catch installed on BeginDeferred (source-gated: " "trigger_wispSwarm_C -> wisp_C, piramidSpawner_C -> killerwisp_C + piramid2_C, " - "jellyfishPath_C -> jellyfish_C; EX_CallMath spawns now enroll)"); + "jellyfishPath_C -> jellyfish_C, trigger_eventer_C -> event outputs, " + "grayEventController_C -> grayTest_C, superEgger_C + ticker_egSpawner_C -> eg_C, " + "tentacleBallsFollower_C -> tentacleBall_C; EX_CallMath spawns now enroll)"); } else { UE_LOGW("npc-sync[ex-spawn]: InstallPostHook FAILED -- EX_CallMath creature spawns will " "NOT mirror this session (event-swarm wisps host-only)"); diff --git a/src/votv-coop/src/coop/world/spawn_authority.cpp b/src/votv-coop/src/coop/world/spawn_authority.cpp index de4e7dd8d..76ff9ce30 100644 --- a/src/votv-coop/src/coop/world/spawn_authority.cpp +++ b/src/votv-coop/src/coop/world/spawn_authority.cpp @@ -5,14 +5,23 @@ // spawn refused the lifespan reaps it; the yellow-wisp ticker spawns at a navmesh random-walk point, // not around the player, and its product is host-mirrored, so the client must not run its own // spawner; the sky-wisp ticker spawns the sky wisps at absolute map coordinates, so the host rolls -// and clients mirror through the source-gated catch and the variant allowlist; the roach master's +// and clients mirror through the source-gated catch and the variant allowlist; the night-egg +// ticker rolls the same world-anchored shape (a weighted night roll mints one eg_C at random +// absolute map coordinates and writes gamemode->eg), so the host rolls and a client's own tick is refused; +// the roach master's // summon and its three looping timer entries fire independently of its tick. The tick rows: the // insomniac and fossilhound tickers roll inside their tick with no delay chains or reap duties, so // refusing the tick stops the roll and the product, and the products are host-mirrored; the roach // master's tick drives roach movement, the food-eat mutation and crush traces, which a client running // it would diverge, so it and its summoner are refused while the roach sync drives the client // population. The jellyfish path's spawn makes seven fish inside its graph, and the host's are -// mirrored through the source-gated catch, so a client's own, its 18:00 roll's, is refused. +// mirrored through the source-gated catch, so a client's own, its 18:00 roll's, is refused. The +// event creatures (the vent crawler, the gray pack, the eggs and the tentacle balls) are the same +// shape pushed one level down: on a client they exist only as host mirrors, so their AI, timers, +// overlaps and despawn verbs are refused while the AnimBP and the pose stream stay; and the +// bodies that mint them inside the gray controller, the balls follower and the super egger are +// refused on the client, because their BeginDeferred calls are bytecode-internal and no spawn +// interceptor ever sees them. The eventer's own verbs are event_fire_sync's. #include "coop/world/spawn_authority.h" @@ -54,6 +63,11 @@ constexpr Row kRows[] = { // Sky wisps: world-anchored, so the host rolls; the source-gated catch and the variant // allowlist mirror the products. {L"ticker_wispSpawner_C", L"ReceiveTick", "wispSpawner.ReceiveTick"}, + // The night-egg ticker: the same world-anchored shape -- its tick re-arms a 60-300 s + // interval and rolls a weighted night spawn of one eg_C at random absolute map coordinates, + // writing gamemode->eg (ticker_egSpawner_C UG:6-44). The host rolls, the catch mirrors the + // egg; a client's own tick would mint a second, unsynced egg. + {L"ticker_egSpawner_C", L"ReceiveTick", "egSpawner.ReceiveTick"}, // The space jellyfish: the host's run is mirrored through the source-gated catch. {L"jellyfishPath_C", L"spawn", "jellyfishPath.spawn"}, // The roach sim's entries: the ticker's cross-object call and the three looping timer @@ -67,6 +81,74 @@ constexpr Row kRows[] = { {L"ticker_fossilhoundSpawner_C", L"ReceiveTick", "fossilhoundSpawner.ReceiveTick"}, {L"cockroachMaster_C", L"ReceiveTick", "cockroachMaster.ReceiveTick"}, {L"ticker_roachSummoner_C", L"ReceiveTick", "roachSummoner.ReceiveTick"}, + + // The story-event creatures: on a client every instance is a host mirror -- the local + // spawn paths are suppressed through the allowlist or refused below -- so the gameplay + // entries are refused class-wide. Animation and presentation stay: nothing here touches + // the AnimBP, the timelines simply never arm without their owning entries, and the host's + // pose stream owns the transform. + // ventCrawler_C: the whole scene is the BeginPlay body -- it binds the prop_vent_C ref, + // plays the moveTL timeline (which itself SetActorLocations the crawl), arms the vent-bang + // and footstep events, and ends in the door writes, the setEvent flag and K2_DestroyActor + // (ventCrawler_C UG @2669). The box overlap is the end-of-crawl contact: a client pawn + // touching the mirror must not open the doors locally (@2363). + {L"ventCrawler_C", L"ReceiveBeginPlay","ventCrawler.ReceiveBeginPlay"}, + {L"ventCrawler_C", L"BndEvt__ventCrawler_Box_K2Node_ComponentBoundEvent_0_ComponentBeginOverlapSignature__DelegateSignature", + "ventCrawler.BoxBeginOverlap"}, + // grayTest_C: BeginPlay re-attaches the mesh, gathers the pack and arms the wander MoveTo + // plus the two looping 3 s timers and the 5-10 s despawn timer (@1117). The sphere overlap + // is the player-catch: on a mainPlayer touch it calls grayController->despawn() and + // deacCams() (@1508). deacCams/disableCams deactivate the client's own cameras, rdrone and + // kerfur -- the damage write, not animation. + {L"grayTest_C", L"ReceiveBeginPlay","grayTest.ReceiveBeginPlay"}, + {L"grayTest_C", L"BndEvt__grayTest_Sphere_K2Node_ComponentBoundEvent_0_ComponentBeginOverlapSignature__DelegateSignature", + "grayTest.SphereBeginOverlap"}, + {L"grayTest_C", L"deacCams", "grayTest.deacCams"}, + {L"grayTest_C", L"disableCams", "grayTest.disableCams"}, + // eg_C: BeginPlay arms the wander (@1466); the tick bobs the mesh but also rolls the + // camera-distance check into retrieve() and the rendered/setEvent writes (@15) -- refusing + // it costs the bob, while letting it run lets a client egg hop on its own. retrieve() is + // the fly-away despawn: collision off, gravity off, a 10 s delay into gamemode->eg=null + // and setEvent (@1476). OnLanded continues the move chain (@1869). + {L"eg_C", L"ReceiveBeginPlay","eg.ReceiveBeginPlay"}, + {L"eg_C", L"ReceiveTick", "eg.ReceiveTick"}, + {L"eg_C", L"OnLanded", "eg.OnLanded"}, + {L"eg_C", L"retrieve", "eg.retrieve"}, + // tentacleBall_C: BeginPlay resolves the anim instance, starts Timeline_0 and the ambient + // loop, and arms the 15-30 s timer and the first move (@7603). The tick is the brain: + // proximity scans, the pindown/chase decision, the stare updates (@8872). move() is the + // MoveTo verb it reaches (@10046); the sphere hit is the melee contact (@10314). The + // montage-notify talk/light callbacks and step() stay -- sound and light presentation. + {L"tentacleBall_C", L"ReceiveBeginPlay","tentacleBall.ReceiveBeginPlay"}, + {L"tentacleBall_C", L"ReceiveTick", "tentacleBall.ReceiveTick"}, + {L"tentacleBall_C", L"move", "tentacleBall.move"}, + {L"tentacleBall_C", L"BndEvt__tentacleBall_Sphere_K2Node_ComponentBoundEvent_0_ComponentHitSignature__DelegateSignature", + "tentacleBall.SphereHit"}, + + // The event spawners themselves: a client's copies must never mint children. Every + // BeginDeferred inside these graphs is bytecode-internal, so the spawn interceptor never + // sees them; refusing the bodies that contain them is the only suppression that holds. + // grayEventController_C (level-placed on both sides): spawn() is the only body with the + // grayTest BeginDeferred (@2617, the marker loop) -- it also plays the Audio, writes + // isRaining=false and runs the deac-cams SphereOverlap. begin() (@3064) is the eventer's + // entry -- the player punch, the transformer break, the car zap -- and turnedon (@3388) + // and the triggerbox overlap (@3309) are its local activations. + {L"grayEventController_C", L"begin", "grayEventController.begin"}, + {L"grayEventController_C", L"turnedon", "grayEventController.turnedon"}, + {L"grayEventController_C", L"spawn", "grayEventController.spawn"}, + {L"grayEventController_C", L"BndEvt__grayEventController_triggerbox_K2Node_ComponentBoundEvent_0_ComponentBeginOverlapSignature__DelegateSignature", + "grayEventController.triggerboxOverlap"}, + // tentacleBallsFollower_C (level-placed on both sides): runTrigger is the five-ball spawn + // loop plus the setBuddies pass and the cleanup timers (@3432). The tick moves the follow + // marker along the spline and writes setEvent at the resume and end points (@2012); the + // client's copy has an empty balls array and nothing to guide. + {L"tentacleBallsFollower_C", L"runTrigger", "tentacleBallsFollower.runTrigger"}, + {L"tentacleBallsFollower_C", L"ReceiveTick", "tentacleBallsFollower.ReceiveTick"}, + // superEgger_C: a client's copy is a world-actor mirror, but FinishSpawning runs its + // BeginPlay, which builds the spawn grid and chains spwn() -- the only body with the eg_C + // BeginDeferred (@1399, @1316). Refusing both keeps the mirror a husk. + {L"superEgger_C", L"ReceiveBeginPlay","superEgger.ReceiveBeginPlay"}, + {L"superEgger_C", L"spwn", "superEgger.spwn"}, }; constexpr int kRowCount = static_cast(std::size(kRows)); constexpr int kTagBase = 0x53410000; // 'SA', then the row From 08fba2c09be0fe4a9af3e48e86cb178839b9b36e Mon Sep 17 00:00:00 2001 From: pelmentor Date: Sat, 10 Oct 2026 03:00:39 +0600 Subject: [PATCH 045/306] [coop] a puppet is refused damage and fire on IsPuppet alone Both refusals returned early while this machine's local pawn read null, for a death or a travel, and before IsPuppet: an ignite let through then burns the puppet for the session, since only its own tick, which it does not run, would put it out. The puppet's flame retries a missing component once a second and says so once, and a new body starts with no flame state. A thrown container's held birth slice is logged as held, not as a refused write, and devices.md says it is held, the per-author caps, and what a joiner during a transfer gets. players.md, the dispatch map and the code map name the ignite watch, the burning bit's late join and container_birth. Co-Authored-By: Claude Opus 5.5 --- docs/code-map.md | 2 +- docs/coop-dispatch-visibility.md | 3 +- docs/devices.md | 8 +++-- docs/players.md | 4 ++- .../include/coop/player/remote_player.h | 15 +++++++-- .../coop/props/container_write_policy.h | 3 +- src/votv-coop/include/ue_wrap/actors/puppet.h | 3 +- .../src/coop/player/local_streams.cpp | 2 +- .../src/coop/player/player_damage.cpp | 22 ++++++------- .../src/coop/player/remote_player.cpp | 31 +++++++++++-------- .../coop/props/container_contents_sync.cpp | 2 +- .../src/coop/props/container_write_policy.cpp | 9 +++--- 12 files changed, 63 insertions(+), 41 deletions(-) diff --git a/docs/code-map.md b/docs/code-map.md index d737bab4a..cd77e9fc0 100644 --- a/docs/code-map.md +++ b/docs/code-map.md @@ -23,7 +23,7 @@ One folder, one concept, named after it. `src/votv-coop/src/` holds the implemen | `coop/session/` | the per-tick orchestrator and the join | `net_pump` (connect and disconnect edges, announcements), `session_manager`, `player_handshake` with `_version` (the game+build gate), `_nick`, `_prefs`, `_roster` (the Join and PlayerJoined messages), `join_seed`, `join_progress`, `world_load_episode`, `host_mode`, `pause_guard` (no pause while connected), `shutdown`, `teleport_client`, `subsystems`, `local_grants` (this machine's local dev grants) and `grants_sync` (their projection from the host, kind 168) | | `coop/element/` | entity identity and lifecycle: the one owner of eid to actor | `registry`, `element`, `mirror_manager` + `mirror_managers`, `identity_create` (create-or-adopt a mirror), `identity_destroy` (retire one), `element_deleter` (deferred destroy), `death_seam` (an element's end of play, or a subscribed class's, announced once to the lanes that subscribed), `portable_identity` (a name both peers compute for an actor the save does not key), `intent_authority`, `object_scan_hub` (the one pass over the object index that every index consumer rides), `quiescence_drain`, `mirror_defer`, `lerp_window`, and the element kinds `prop`, `npc`, `player`, `world_actor` | | `coop/player/` | remote players and per-player state | `remote_player` + `remote_player_ragdoll`, `puppet_drive`, `puppet_carry_drive`, `puppet_body_yaw`, `puppet_footsteps` (driving the engine pawn), `players_registry`, `player_profile_store` (the host's copy of every player's profile: held in memory, cut to disk with the world save), `roster` + `roster_ledger` (who is in each slot), `nickname_arbiter`, `nick_color`, `nameplate`, `hand_item`, `local_body`, `local_streams`, `client_model` + `skin_registry` + `skin_preview` + `skin_effects` (skins), `movement_ledger`, `player_damage`, `death_revive` + `run_end_travel` (a trip to the menu the player did not ask for is refused and answered with a revive), `ragdoll_gate`, `sleep_sync`, `item_activate`, `flashlight_click_sound`, `stat_orders` (an admin's order to, and query of, one player's stats and effects) + `stat_orders_wire` (their bytes, their checks, and the table of the ones awaiting an answer) | -| `coop/props/` | physics props, piles and trash | `prop_lifecycle` (the destroy seam), `prop_snapshot` + `snapshot_census` (the connect snapshot), `remote_prop` with `_spawn`, `_convert`, `_physics`, `_destroy` (the receivers), `host_spawn_watcher`, `prop_key_index`, `prop_synth_key`, `save_identity_bind` + `save_identity_map` (bind save-loaded actors to host eids in the join window), `pile_spawn_bind`, `trash_mirror` (the client's mirror of either trash form, the game's own actor parked), `trash_morph_gate` (the verbs a client refuses so it never authors a transition), `trash_sweep` (the clumps a broom stroke sets rolling: their ids at birth, a carry nobody holds, the roll), `trash_channel`, `trash_grab_intent`, `trash_collect_sync`, `trash_pile_sync`, `trash_use_intercept`, `trash_clump_pose_stream`, `prop_drop_intent`, `prop_spawn_authoring` (did a PLAYER's spawn verb author this birth, or the world's own churn -- the fact the drop-intent seam admits a catalog spawn on), `prop_save_data` (each prop's own save record, by Key), `prop_stick_sync`, `container_contents_sync` + `container_write_policy` (a world container slice, and the host arbitration a client-authored one passes) + `container_park` (the holding pen for a slice whose container has not arrived yet) + `container_view_close` (a player's view into a container ends where the player's reach does), `grab_observer`, `registry_reaper`, `join_membership_sweep`, `unresolved_pose_ledger`, `prop_echo_suppress`, `prop_wire_parity`, `prop_census`, `prop_container_extract`, `prop_destroy_seam`, `prop_fresh_spawn`, `prop_element_tracker`, `prop_sound`, `active_drive` | +| `coop/props/` | physics props, piles and trash | `prop_lifecycle` (the destroy seam), `prop_snapshot` + `snapshot_census` (the connect snapshot), `remote_prop` with `_spawn`, `_convert`, `_physics`, `_destroy` (the receivers), `host_spawn_watcher`, `prop_key_index`, `prop_synth_key`, `save_identity_bind` + `save_identity_map` (bind save-loaded actors to host eids in the join window), `pile_spawn_bind`, `trash_mirror` (the client's mirror of either trash form, the game's own actor parked), `trash_morph_gate` (the verbs a client refuses so it never authors a transition), `trash_sweep` (the clumps a broom stroke sets rolling: their ids at birth, a carry nobody holds, the roll), `trash_channel`, `trash_grab_intent`, `trash_collect_sync`, `trash_pile_sync`, `trash_use_intercept`, `trash_clump_pose_stream`, `prop_drop_intent`, `prop_spawn_authoring` (did a PLAYER's spawn verb author this birth, or the world's own churn -- the fact the drop-intent seam admits a catalog spawn on), `prop_save_data` (each prop's own save record, by Key), `prop_stick_sync`, `container_contents_sync` + `container_write_policy` (a world container slice, and the host arbitration a client-authored one passes) + `container_park` (the holding pen for a slice whose container has not arrived yet) + `container_birth` (a container that changes hands through the world: the thrower's slice, the host's awaited copy) + `container_view_close` (a player's view into a container ends where the player's reach does), `grab_observer`, `registry_reaper`, `join_membership_sweep`, `unresolved_pose_ledger`, `prop_echo_suppress`, `prop_wire_parity`, `prop_census`, `prop_container_extract`, `prop_destroy_seam`, `prop_fresh_spawn`, `prop_element_tracker`, `prop_sound`, `active_drive` | | `coop/creatures/` | NPCs and creatures | `npc_sync` + `npc_sync_install` (the spawn seam), `npc_mirror`, `npc_adoption`, `npc_pose_host`, `npc_pose_drive`, `npc_world_enum`, `kerfur_convert` with `_host` and `_client`, `kerfur_entity`, `kerfur_reconcile`, `kerfur_prop_adoption`, `kerfur_command`, `kerfur_menu_input`, `kerfur_form_assembler` (the prop-to-NPC conversion cycle), `owner_entity_sync` (creatures whose AI reads the local player: owned per peer, visible to all), `piramid_sync`, `roach_sync`, `wisp_attack_sync`, `wisp_grab_hold`, `wisp_tear_mirror` | | `coop/interactables/` | keyed device state | doors, lights, light groups, containers, garage, appliances, lockers: `interactable_sync` + `interactable_channel`; `keypad_sync` + `keypad_verbs`, `verb_lanes` (the lanes on a device's own verbs at the script gate, wired in one place), `toggle_verbs` (a symmetric device's state at the verb that writes it), `window_sync`, `grime_sync`, `turbine_sync`, `drone_sync`, `device_occupancy`, `garbage_sync`. The signal workstation: `signal_sync`, `signal_catch_sync`, `signal_wire`, `dish_sync`, `dish_calib_sync`, `download_arm_sync` (the download's arm and reset, the host's), `desk_input_sync`, `desk_sim_sync`, `desk_ping_sync` (the ping's verdict, rolled on the host), `desk_verb_intent` and `desk_verb_effects` (the desk's save-family presses, run by the host, their gloss, stat and sounds sent to the presser), `desk_cursor_sync`, `desk_snd_fx`, `deck_play_sync`, `comp_sync` (the refiner's decode, the host's), `console_state_sync`, `drive_sync`, `drive_payload_sync` (a drive's row, the host's), `eraser_press_intent` (the eraser's delete, run by the host), `drive_rack_sync`, `tape_caddy_sync`, `physmods_sync`, `serverbox_sync`, `server_upgrade_sync` (a server box's physical upgrades), `sat_console_sync` and `sat_console_table` (the SAT console's shared commands, run on a terminal the host keeps for each typist, and which command runs where), `laptop_sync`, `portable_pc_lid` (the portable PC's lid, on its own verb), `laptop_buffer_sync`, `floppybox_sync`, `floppy_slot_sync` and `floppy_slot_entry` (a device's disc slot: the state, and which overlap into it counts as an insert), `meadow_db_sync`, `meadow_db_writers`, `meadow_db_join`, `meadow_db_park` and `meadow_db_hash` (the laptop's signal database, the bodies that write it, a joiner's seed of it, its inbound lines that cannot apply yet, and the database as content hashes). The ATV: `atv_sync`, `atv_spawn_park` (a runtime ATV's spawn held until its world is ready), `atv_corrector`, `atv_hit_guard`, `atv_condition_sync` | | `coop/world/` | global and ambient state | `time_sync`, `sky_sync`, `weather_sync` with `weather_rain`, `weather_fog`, `weather_lightning`, `weather_redsky`, `weather_event_births`, `firefly_sync`, `spawn_authority` (shared-world spawners run on the host only), `event_fire_sync`, `event_active_sync`, `event_cue_sync`, `alarm_sync`, `balance_sync`, `email_sync`, `daily_task_sync`, `power_grid` (the generators run on the host) + `power_decay` (their wear dice) + `power_puzzle` (their repair puzzles) + `power_upgrade` (the upgrades an install spent) + `power_panel` (the panel's one author), `world_actor_sync` + `world_actor_mirror` | diff --git a/docs/coop-dispatch-visibility.md b/docs/coop-dispatch-visibility.md index c3cce026e..4fd47dd11 100644 --- a/docs/coop-dispatch-visibility.md +++ b/docs/coop-dispatch-visibility.md @@ -139,7 +139,8 @@ Never spawn from an observer directly; post it. `[V]` | the weather-event rolls (black fog, rolling fog) | `EX_LocalVirtualFunction` | no | a field poll on the host for the rolling fog (the black fog has no host lane yet); a birth catch at the finish-spawning seam on clients `[V]` | | the red sky's toggle (`spawnRedSky`) | `EX_LocalVirtualFunction` through a context switch (`EX_Context` on the gamemode), from the day cycle's graph and the cheat menu's | invisible to a ProcessEvent hook | the script-body gate: the host sends its red sky as the toggle returns, a client refuses its own; `[V]` on a reflected call, the Blueprint route `[RD]` by the gate's contract | | the impact damage entries | native impact system into a Blueprint event | yes, and interceptable | cancelled on any body that is not the local player `[V]` | -| the player's damage verb (`Add Player Damage`) | `EX_LocalVirtualFunction` on itself, and the same opcode through a context switch from each attacker | no, on every one of its call sites | the script-body gate, refusing per call by the verb's own `source` argument -- the attacker the Blueprint passes `[V]` | +| the player's damage verb (`Add Player Damage`) | `EX_LocalVirtualFunction` on itself, and the same opcode through a context switch from each attacker | no, on every one of its call sites | the script-body gate, refusing per call by the verb's own `source` argument -- the attacker the Blueprint passes -- and by its TARGET: on a peer's puppet it is always refused `[V]` | +| the player's `ignite` | `EX_LocalVirtualFunction` from the burning body's chain, interface calls from `comp_flammable` and `explosion` | no | the script-body gate, class-scoped to `mainPlayer_C`, refused on a peer's puppet; `attemptIgnite` is an empty stub on the player and `startBurning` is reached only from `ignite` `[V]` | | a base cleaner's begin-play (it box-overlaps and destroys) | engine | yes | a client cancels it; the verb is declared on the base class, so a leaf variant that declares nothing resolves only by climbing `[V]` | | the lethal chain (damage, kill, ragdoll, fallen) | `EX_LocalVirtualFunction` | no | the death lane cuts at the native level open below it `[V]` | | the level travel (`loadLevel`, `transition`) | `EX_LocalVirtualFunction` | no, to both the detour and the native seam | the script-body gate watches `lib_C::loadLevel` BY NAME and cancels per call, judging the author the call still carries -- the only place it is still there (`coop/player/run_end_travel`) `[V]` | diff --git a/docs/devices.md b/docs/devices.md index 8846a8d66..bc9632812 100644 --- a/docs/devices.md +++ b/docs/devices.md @@ -118,8 +118,12 @@ container is the newest version of the transfer, an edit made while the first on and is taken without a base. Whatever the host put in meanwhile is kept beside it, and the merged contents go to every peer, the thrower included; if the two do not fit one slice, the thrower's is applied alone and the host's records are dropped, with a warning. A birth slice is taken unjudged -but not unbounded: it spends its author's arrival budget like an edit. The wait ends after thirty -seconds or when the thrower leaves. A container the host throws publishes its contents at the express, since the +but not unbounded: it spends its author's arrival budget like an edit, and past that budget it is +held in the pen and replayed rather than refused, since it is the thrower's only copy and is sent +once. The pen holds eight per author, so a ninth simultaneous held slice is lost, said. Each author +has at most 64 transfers in flight, its own oldest given up past that. The wait ends after thirty +seconds or when the thrower leaves. A peer that joins during a transfer gets the container from the +fan-out that completes it, not from its seed. A container the host throws publishes its contents at the express, since the mirrors are born empty, retrying while its slot is not readable. Applying a slice raw-writes the receiver's own array slot and then re-derives everything a setter diff --git a/docs/players.md b/docs/players.md index ae13f9da1..67f6b04d6 100644 --- a/docs/players.md +++ b/docs/players.md @@ -135,7 +135,9 @@ fire sets a player alight: its burn loop turns this machine's view and plays its second, and only its own tick, which a puppet does not run, would put it out. The fire is not yet a shared hazard: the owner does not burn from a fire only another machine shows, until fire itself is host-authored world state. A burning owner's flame is shown: its burning time rides the pose -stream as a display bit, and the puppet switches the body's own flame on and off, nothing else. +stream as a display bit, and the puppet switches the body's own flame on and off, nothing else. A +peer that joins sees it from the next pose, since the bit is level, not an edge. + Enemy hits are the one relay: the host runs the enemies, and when one hits a peer's puppet the host sends that peer a reliable damage event, which the peer applies on its own pawn, armour and inventory mitigation included, so its health stream and hurt flash follow. diff --git a/src/votv-coop/include/coop/player/remote_player.h b/src/votv-coop/include/coop/player/remote_player.h index 3f6ecfdb4..6052b4058 100644 --- a/src/votv-coop/include/coop/player/remote_player.h +++ b/src/votv-coop/include/coop/player/remote_player.h @@ -111,9 +111,17 @@ class RemotePlayer { int32_t RagdollBodyIdx() const { return ragdoll_.BodyIdx(); } void* actor() const { return actor_; } - void ShowBurning(bool burning); private: + // The owner's burning bit onto this puppet's flame; SetTargetPose is its one caller, so the flame + // follows the bit and nothing else. + void ShowBurning(bool burning); + void ResetBurning() { + burningShown_ = false; + burningTried_ = false; + burningFailSaid_ = false; + burningNextTry_ = {}; + } // Apply curPos_, curYaw_ and curSpeed_ to the engine actor and the AnimBP. Called by Tick once // per frame whether or not interpolation is active, so nothing that moved the actor between // frames leaves drift. @@ -194,7 +202,7 @@ class RemotePlayer { // own step every native stride, since the puppet's BP tick (where the game's accumulator lives) // is suppressed. coop::puppet_footsteps::Stride footsteps_{}; - // The state bits (kStateBitInAir and reserved bits), snapped on every SetTargetPose, never + // The state bits (in air, ragdolled, burning), snapped on every SetTargetPose, never // interpolated. ApplyToEngine drives the movement mode from bit 0: set is falling, clear is // walking, which the AnimBP reads to gate the foot IK. uint8_t curStateBits_ = 0; @@ -207,7 +215,8 @@ class RemotePlayer { // component, switched on and off; never ignite, its damage loop, its 2D pain sound or its camera // turn, which are the owner's. Re-asserted while the bit holds, so a respawned body shows it again. bool burningShown_ = false; - bool burningTried_ = false; // a switch to the other state was attempted and not yet taken + bool burningTried_ = false; // the last switch did not dispatch; retried each second + bool burningFailSaid_ = false; // the missing flame was said, once per body std::chrono::steady_clock::time_point burningNextTry_{}; ue_wrap::FVector targetPos_{}; float targetYaw_ = 0.f; diff --git a/src/votv-coop/include/coop/props/container_write_policy.h b/src/votv-coop/include/coop/props/container_write_policy.h index 9902a18c4..be03b74bc 100644 --- a/src/votv-coop/include/coop/props/container_write_policy.h +++ b/src/votv-coop/include/coop/props/container_write_policy.h @@ -87,8 +87,9 @@ Decision Accept(uint32_t eid, uint64_t baseHash, uint8_t authorSlot, uint64_t no // The rate half alone, Accept or TooFast: an arrival spends one of its author's window, a replay // nothing. Accept runs it first; the lane runs it by itself for a slice it takes unjudged (a thrown // container's birth), which must not be free of the bound either. +// `held` says what the caller does with a TooFast slice, for the log: refuses it, or holds it in the pen. Decision SpendArrival(uint32_t eid, uint8_t authorSlot, uint64_t nowMs, coop::net::Session& session, - Source src); + Source src, bool held = false); // The host has told the world what this container holds, by any route -- a fan-out or a targeted // connect seed. This is the compare-and-swap baseline a later client write is judged against. diff --git a/src/votv-coop/include/ue_wrap/actors/puppet.h b/src/votv-coop/include/ue_wrap/actors/puppet.h index dab1dc7fd..c977d71ad 100644 --- a/src/votv-coop/include/ue_wrap/actors/puppet.h +++ b/src/votv-coop/include/ue_wrap/actors/puppet.h @@ -136,7 +136,8 @@ void DisableMovementTick(void* actor); // The body's own flame (mainPlayer_C.burningEffect) switched on or off -- the component alone, never // ignite, its damage loop, its pain sound or its camera turn, which are the owner's. False when the -// component does not resolve or the switch did not take. Game thread. +// component does not resolve or the switch could not be dispatched; nothing reads its state back. +// Game thread. bool SetBurningFlame(void* mainPlayerActor, bool on); } // namespace ue_wrap::puppet diff --git a/src/votv-coop/src/coop/player/local_streams.cpp b/src/votv-coop/src/coop/player/local_streams.cpp index d6776d815..2b54e0336 100644 --- a/src/votv-coop/src/coop/player/local_streams.cpp +++ b/src/votv-coop/src/coop/player/local_streams.cpp @@ -115,7 +115,7 @@ bool ReadLocalPose(void* local, void* controller, coop::net::PoseSnapshot& out) // The burning bit: the body's own fire runs while its burningTime is above zero (the condition // mainPlayer_C's tick tests before it turns the flame off), so the other screens show the flame on // the puppet. Display only: a receiver never ignites anything. ue_wrap::vitals reads it off the - // gamemode's own player, which is `local`. + // gamemode's own player, the body `local` names but for a few frames across a respawn. { float burn = 0.f; if (ue_wrap::vitals::Read(ue_wrap::vitals::Field::BurningTime, &burn) && burn > 0.f) diff --git a/src/votv-coop/src/coop/player/player_damage.cpp b/src/votv-coop/src/coop/player/player_damage.cpp index 7a9e9b73c..904658d80 100644 --- a/src/votv-coop/src/coop/player/player_damage.cpp +++ b/src/votv-coop/src/coop/player/player_damage.cpp @@ -83,8 +83,8 @@ bool OnImpactEntryPre(void* self, void* /*params*/) { // through an impact entry: the body's own ubergraph calls Add Player Damage on itself once a second // for as long as it burns. A peer's puppet that walked through fire only this machine shows caught // it here and burned for the rest of the session, and every tick ran the damage body against this -// machine's player: its camera shook once a second (measured with coop/dev/damage_probe: [DMG] -// PUPPET lines at 1 Hz for seven minutes). The puppet's owner sees no such fire and takes nothing, +// machine's player: its camera shook once a second (the burn loop's own once-a-second Delay; the +// [DMG] probe, coop/dev/damage_probe, logs each call). The puppet's owner sees no such fire and takes nothing, // which is the victim-authoritative answer the impact cancel already gives. Watched at the // script-body gate because the verb is Blueprint-internal on every route. The mod's own calls get no // pass: the origin flag spans every Blueprint call nested inside one of ours, so it proves nothing @@ -95,11 +95,10 @@ bool g_damageWatchDone = false; uint32_t g_verbCanceled = 0; ue_wrap::script_gate::Verdict OnDamageVerbPre(const ue_wrap::script_gate::Call& c) { - auto* s = g_session.load(std::memory_order_acquire); - if (!s || !s->connected() || !c.object) return ue_wrap::script_gate::Verdict::Run; - void* localPawn = g_localPawn.load(std::memory_order_acquire); - if (!localPawn || c.object == localPawn) return ue_wrap::script_gate::Verdict::Run; - if (!coop::players::Registry::Get().IsPuppet(c.object)) return ue_wrap::script_gate::Verdict::Run; + // IsPuppet alone: no session or local-pawn early-out in front of it, since the local pawn reads + // null for a whole death or travel window, and one call let through then burns for the session. + if (!c.object || !coop::players::Registry::Get().IsPuppet(c.object)) + return ue_wrap::script_gate::Verdict::Run; const uint32_t n = ++g_verbCanceled; if (n <= 5 || (n % 100) == 0) UE_LOGI("player_damage: canceled the damage verb on a peer's puppet %p (#%u) -- its owner " @@ -125,11 +124,10 @@ bool g_igniteWatchDone = false; uint32_t g_igniteCanceled = 0; ue_wrap::script_gate::Verdict OnIgnitePre(const ue_wrap::script_gate::Call& c) { - auto* s = g_session.load(std::memory_order_acquire); - if (!s || !s->connected() || !c.object) return ue_wrap::script_gate::Verdict::Run; - void* localPawn = g_localPawn.load(std::memory_order_acquire); - if (!localPawn || c.object == localPawn) return ue_wrap::script_gate::Verdict::Run; - if (!coop::players::Registry::Get().IsPuppet(c.object)) return ue_wrap::script_gate::Verdict::Run; + // IsPuppet alone: no session or local-pawn early-out in front of it, since the local pawn reads + // null for a whole death or travel window, and one call let through then burns for the session. + if (!c.object || !coop::players::Registry::Get().IsPuppet(c.object)) + return ue_wrap::script_gate::Verdict::Run; const uint32_t n = ++g_igniteCanceled; if (n <= 5 || (n % 100) == 0) UE_LOGI("player_damage: refused setting a peer's puppet %p alight (#%u) -- its fire is its owner's", diff --git a/src/votv-coop/src/coop/player/remote_player.cpp b/src/votv-coop/src/coop/player/remote_player.cpp index c3a1d0a11..49f9b38b9 100644 --- a/src/votv-coop/src/coop/player/remote_player.cpp +++ b/src/votv-coop/src/coop/player/remote_player.cpp @@ -57,6 +57,7 @@ float Dist3(const ue_wrap::FVector& a, const ue_wrap::FVector& b) { } // namespace bool RemotePlayer::Spawn(const std::string& skinName) { + ResetBurning(); // a new body owes no flame state to the one before it // Registry::Local() first (controller-filtered: the genuine local, not a puppet, which is also // a mainPlayer_C); FindObjectByClass as the fallback during the splash and pre-possession // window, where no controller is attached and no puppet exists yet. Without it Spawn failed @@ -387,29 +388,33 @@ void RemotePlayer::SetRagdollPose(const coop::net::RagdollPoseSnapshot& snap) { } void RemotePlayer::ShowBurning(bool burning) { - // The wanted state is the owner's bit; the shown state changes only when the flame switch - // succeeded. Either direction is retried about once a second until it takes, and a held ON is + // The wanted state is the owner's bit; the shown state changes only when the flame switch was + // dispatched. A change is tried at once and then about once a second until it takes; a held ON is // re-asserted at the same cadence, which covers a body whose components were rebuilt. const auto now = std::chrono::steady_clock::now(); - const bool due = now >= burningNextTry_; - if (burning == burningShown_ && !(burning && due)) return; - if (burning != burningShown_ && !due && burningTried_) return; + const bool change = burning != burningShown_; + if (!change && !(burning && now >= burningNextTry_)) return; + if (change && burningTried_ && now < burningNextTry_) return; burningNextTry_ = now + std::chrono::seconds(1); - burningTried_ = true; if (!actor_ || !R::IsLiveByIndex(actor_, internalIdx_)) return; - if (!ue_wrap::puppet::SetBurningFlame(actor_, burning)) return; - if (burning != burningShown_) { - UE_LOGI("remote_player: puppet %p flame %s (its owner's burning bit)", actor_, burning ? "on" : "off"); - burningTried_ = false; + if (!ue_wrap::puppet::SetBurningFlame(actor_, burning)) { + burningTried_ = true; + if (!burningFailSaid_) { + burningFailSaid_ = true; + UE_LOGW("remote_player: puppet %p has no flame to switch %s (burningEffect did not resolve) -- " + "its owner's burning is not shown", actor_, burning ? "on" : "off"); + } + return; } + burningTried_ = false; + if (change) + UE_LOGI("remote_player: puppet %p flame %s (its owner's burning bit)", actor_, burning ? "on" : "off"); burningShown_ = burning; } void RemotePlayer::Destroy() { if (!actor_) return; - burningShown_ = false; - burningTried_ = false; - burningNextTry_ = {}; + ResetBurning(); // The AnimInstance dies with the actor, so no field cleanup. The ragdoll display body is a // separate actor that would outlive the puppet as an orphan: torn down first, with its latches. ragdoll_.TeardownForDestroy(); diff --git a/src/votv-coop/src/coop/props/container_contents_sync.cpp b/src/votv-coop/src/coop/props/container_contents_sync.cpp index 6fee6b07e..45949b081 100644 --- a/src/votv-coop/src/coop/props/container_contents_sync.cpp +++ b/src/votv-coop/src/coop/props/container_contents_sync.cpp @@ -349,7 +349,7 @@ Ingest ParseAndApply(const std::vector& blob, uint32_t& outEid, uint8_t if (birth) { auto* s = g_session.load(std::memory_order_acquire); if (!s) return Ingest::Handled; - if (wp::SpendArrival(outEid, senderSlot, NowMs(), *s, src) == wp::Decision::TooFast) + if (wp::SpendArrival(outEid, senderSlot, NowMs(), *s, src, /*held=*/true) == wp::Decision::TooFast) return Ingest::Park; } // Host arbitration before anything is touched; a refusal is answered by re-publishing the diff --git a/src/votv-coop/src/coop/props/container_write_policy.cpp b/src/votv-coop/src/coop/props/container_write_policy.cpp index ea6219e6a..0fbf01ed0 100644 --- a/src/votv-coop/src/coop/props/container_write_policy.cpp +++ b/src/votv-coop/src/coop/props/container_write_policy.cpp @@ -83,7 +83,7 @@ Decision JudgeAgainstState(uint32_t eid, uint64_t baseHash, uint64_t nowMs) { } Decision SpendArrival(uint32_t eid, uint8_t authorSlot, uint64_t nowMs, coop::net::Session& session, - Source src) { + Source src, bool held) { // The budget is spent by ARRIVAL, not by acceptance: a refused slice still costs the host a // parse, this arbitration and a corrective re-publish. A replay out of the park spends // nothing -- it arrived once already, and a pen sweeping four times a second would otherwise @@ -97,12 +97,13 @@ Decision SpendArrival(uint32_t eid, uint8_t authorSlot, uint64_t nowMs, coop::ne if (v.refused) { ++g_refused; UE_LOGW("container_contents: CONFLICT eid=%u slot %u -- %d slices inside %llu ms is " - "past the bound; refused for %llu ms. Write REFUSED, and nothing re-published: an " - "author pushing faster must not make the host spend more. Total refused this " - "session: %llu", + "past the bound; refused for %llu ms. %s Total refused this session: %llu", eid, static_cast(authorSlot), v.count, static_cast(kRateWindowMs), static_cast(v.retryMs), + held ? "A birth slice: HELD in the pen and replayed, since it is the thrower's only copy." + : "Write REFUSED, and nothing re-published: an author pushing faster must not make " + "the host spend more.", static_cast(g_refused)); return Decision::TooFast; } From d0e453f911d6d82be3faed8c6b152b1f9c64b772 Mon Sep 17 00:00:00 2001 From: pelmentor Date: Sat, 10 Oct 2026 03:05:21 +0600 Subject: [PATCH 046/306] [coop] the eventer enrols only the event outputs a client keeps inert As a catch source the eventer also mints world actors whose BeginPlay plays gameplay on a mirror (soltomiaCleaning_C writes the client's ATV and mints a sponge), with no refusal rows for them; it now enrols only the vent crawler and the super egger, and the rest stay unmirrored as before. A host deferred spawn renders its source's name once, not once per table row. The row comments say what the refused BeginPlays also lose on a mirror and match the bytecode; npcs-and-kerfur.md says what a joiner mid-event gets; the two maps carry the new sources and rows. Co-Authored-By: Claude Opus 5.5 --- docs/coop-dispatch-visibility.md | 3 +- docs/coop-entity-expression-map.md | 5 +- docs/npcs-and-kerfur.md | 2 +- src/votv-coop/include/coop/dev/spawn_npc.h | 8 +- .../include/coop/world/spawn_authority.h | 7 +- .../src/coop/creatures/npc_world_enum.cpp | 79 +++++++++++-------- .../src/coop/world/spawn_authority.cpp | 43 ++++------ src/votv-coop/src/ui/dev_menu.cpp | 7 +- 8 files changed, 76 insertions(+), 78 deletions(-) diff --git a/docs/coop-dispatch-visibility.md b/docs/coop-dispatch-visibility.md index 4fd47dd11..a6a66525d 100644 --- a/docs/coop-dispatch-visibility.md +++ b/docs/coop-dispatch-visibility.md @@ -158,7 +158,8 @@ Never spawn from an observer directly; post it. `[V]` | a deferred-spawned actor's own initialisation | `EX_LocalVirtualFunction` from its construction script | no; its post observer never fires | the finish-spawning post (keyed) or the deferred-spawn post (keyless) `[V]` | | a keyed prop's `init()` on a save load or in play | its construction script, its ubergraph, `loadData`: Blueprint calls; through ProcessEvent only when we call it ourselves (a pile's re-skin, a drive box's relook) | not to a ProcessEvent observer: 0 of 10,685 keyed bodies on a host and 0 of 11,989 on a joining client came through it; yes to the script-body gate | a name watch on `Init` keeping the bodies with no calling frame (`coop/props/prop_lifecycle`) `[V]` | | a deferred spawn from a native or spawner caller | reaches the detour | yes | the creature and world-actor interceptors; the actor is not positioned yet, so read the transform parameter `[V]` | -| a deferred spawn from a Blueprint graph (the pile morph, the wisp swarm, the pyramid's spawner) | `EX_CallMath` | not to the detour | the native seam, gated by the calling object's class `[V]` | +| a deferred spawn from a Blueprint graph (the pile morph, the wisp swarm, the pyramid's spawner; the story events' eventer, gray controller, super egger, balls follower and night-egg ticker) | `EX_CallMath` | not to the detour | the native seam, gated by the calling object's class, and for the eventer by its output too (only the outputs the spawn authority makes inert) `[V]` | +| the story-event creatures' own bodies on a client (BeginPlay, ticks, overlaps, AI and despawn verbs) and their spawners' mint bodies | every route the VM runs a body | the script-body gate | class-scoped watches in `coop/world/spawn_authority`, refused on an active client session `[V]` | | an event actor's self-destroy at its end | a self-call | no | the host's pose walk retires the dead actor `[V]` | | a finish-spawning from a native caller | reaches the detour | yes | the keyed sandbox-spawn seam `[V]` | | the spawn menu's own `ui_spawnmenu_C::spawn`, and the gamemode verb it calls | a widget delegate into `EX_Context` on the menu's `gamemode` variable, whose inner expression is `EX_LocalVirtualFunction spawnPropThroughGamemode` (measured on the shipped pak, `ui_spawnmenu.json` expr [13]) | the widget's yes, the gamemode verb's no | the script-body gate on `spawnPropThroughGamemode`. The verb alone does not name a player -- `lib_C::replaceProp` and `comp_physicsImpact` reach it too -- so the gate's PRE reads the CALLING frame and only the menu's own `spawn` counts, and the seam under the birth asks `IsBodyActive(verb, menuSpawn)` rather than keeping a counter of its own `[V]` | diff --git a/docs/coop-entity-expression-map.md b/docs/coop-entity-expression-map.md index 285d2a24b..48e254cae 100644 --- a/docs/coop-entity-expression-map.md +++ b/docs/coop-entity-expression-map.md @@ -178,8 +178,9 @@ its self-destroy is retired by the pose walk. `[V]` A second interceptor on the same deferred-spawn call carries a disjoint, name-matched allowlist (the event classes load lazily), a full-rotation pose batch, a spawn that carries scale and a class-interpreted birth blob, and a dead-retire in the pose walk for the actors that destroy -themselves at an event's end. A graph spawner (the pyramid's, the jellyfish path's) is caught at -the native seam. A heading that lives outside the actor's rotation streams as an auxiliary yaw. A +themselves at an event's end. A graph spawner (the pyramid's, the jellyfish path's, and the story +events' spawners for the vent crawler, the grays, the eggs and the tentacle balls) is caught at the +native seam; on a client those creatures' own bodies are refused, so a mirror arms no gameplay. A heading that lives outside the actor's rotation streams as an auxiliary yaw. A mirror's tick is parked and whatever component its class simulates with is stopped, found from the class's members, so the pose drive alone moves it. `[V]` diff --git a/docs/npcs-and-kerfur.md b/docs/npcs-and-kerfur.md index 981fd646b..cf75aa7e7 100644 --- a/docs/npcs-and-kerfur.md +++ b/docs/npcs-and-kerfur.md @@ -62,7 +62,7 @@ leaving one leaves nothing to restore. | sky wisp spawner | tick refused | absolute map coordinates, not player-anchored | | night egg spawner | tick refused | a weighted night roll mints one egg at random absolute map coordinates; the host's egg mirrors through the source-gated catch | | jellyfish path | spawn refused | its seven fish are made inside its graph; the host's run is mirrored through the source-gated catch | -| vent crawler, grays, eggs, tentacle balls | gameplay entries refused (BeginPlay, tick, overlaps, AI and despawn verbs); animation and pose stay | on a client they exist only as host mirrors through the npc lane's allowlist | +| vent crawler, grays, eggs, tentacle balls | gameplay entries refused (BeginPlay, tick, overlaps, AI and despawn verbs); the AnimBP and the pose stay | on a client they exist only as host mirrors through the npc lane's allowlist. A mirror loses what its BeginPlay also set up: the crawler's footsteps, vent break and metal-break burst, a ball's and a gray's mesh attach, a ball's ambient loop. A peer that joins mid-event gets every living creature from the npc lane's join census; the event's own scene is not replayed | | gray controller, balls follower, super egger | the mint and activation bodies refused | their creature BeginDeferred calls are bytecode-internal: no spawn interceptor can see them, so the bodies must not run | | firefly, pinecone | left running | anchored on the local player's camera: each peer owns its roll and the others render a mirror | | eyer | left running | it stalks its own peer's player: each peer owns its eyer and the others render a mirror (`coop/creatures/owner_entity_sync`) | diff --git a/src/votv-coop/include/coop/dev/spawn_npc.h b/src/votv-coop/include/coop/dev/spawn_npc.h index 26b128d99..44b40769b 100644 --- a/src/votv-coop/include/coop/dev/spawn_npc.h +++ b/src/votv-coop/include/coop/dev/spawn_npc.h @@ -24,10 +24,10 @@ void Init(); void SpawnKerfurOmega(); // Test-spawn the classes the npc_sync allowlist added, in front of the host -- THE reliable mirror -// test. The F1 wisps and ventCrawler events do not reliably produce a catchable creature: wisps -// arms an overlap box, and the eventer's ventCrawler spawn uses EX_CallMath, so it bypasses our -// interceptor and lands in a far vent. See spawn_npc.cpp PostSpawnClass. Host-only (dev_gate); safe -// off the game thread. +// test. The F1 wisps and ventCrawler events do not reliably produce a creature in front of the +// player: wisps arms an overlap box, and the eventer's ventCrawler lands in a far vent (its +// EX_CallMath spawn is caught by the npc lane's EX source, not by the interceptor). See +// spawn_npc.cpp PostSpawnClass. Host-only (dev_gate); safe off the game thread. void SpawnKillerWisp(); void SpawnVentCrawler(); diff --git a/src/votv-coop/include/coop/world/spawn_authority.h b/src/votv-coop/include/coop/world/spawn_authority.h index e5617290a..07f00ad26 100644 --- a/src/votv-coop/include/coop/world/spawn_authority.h +++ b/src/votv-coop/include/coop/world/spawn_authority.h @@ -7,11 +7,8 @@ // it is not shared: a spawner whose product has no mirror yet runs on every peer, each rolling its // own. The deer, bp7 and hexahive spawners, measured rolling on a connected client, are such. -// ONE table: each row is a body a client must not run, a spawner's entry function or its tick -- -// and, for the story-event creatures, the AI, timer and contact bodies of an actor that on a -// client exists only as a host mirror, plus the mint bodies of the event spawners whose output -// mints bytecode-internally where no spawn interceptor can see it -- refused at the script gate -// by a watch keyed on the row's class and function names. So a row holds +// ONE table: each row is a body a client must not run, a spawner's entry function or its tick, +// refused at the script gate by a watch keyed on the row's class and function names. So a row holds // for every instance, a late one included, on every dispatch route of the game thread (the gate fires // there, and every spawner body runs there), and for a class the game re-creates with each world; the // refusal is gated on an active client session, so the host and solo play run every body and a diff --git a/src/votv-coop/src/coop/creatures/npc_world_enum.cpp b/src/votv-coop/src/coop/creatures/npc_world_enum.cpp index 5de8e0e9e..354d4e14c 100644 --- a/src/votv-coop/src/coop/creatures/npc_world_enum.cpp +++ b/src/votv-coop/src/coop/creatures/npc_world_enum.cpp @@ -119,37 +119,44 @@ coop::element::ElementId EnrollUntrackedNpcActor(void* obj, const std::wstring& return eid; } -// The EX_CallMath spawn catch. The source spawner classes whose deferred-spawn output is -// host-authoritative and mirrored: the wisps event swarm (up to 32 wisps); the piramid event -// chain (four killer wisps on the NPC lane and one pyramid on the world-actor lane, a spawn -// the interceptor never sees); the ambient sky-wisp ticker and its colour variants (it -// anchors at absolute map coordinates, not around a player, so the host rolls them and the -// client's ticker is cancelled in the spawn authority); the night-egg ticker, the same -// world-anchored shape (its tick's weighted roll mints one eg_C at random absolute map coordinates and -// writes gamemode->eg, and the client's own tick is refused in the spawn authority); and the -// sell gun's coin mint, whose deferred spawn inside the sell verb is bytecode-internal on the -// host's own sale and on the re-commit of a client's sale alike, so the world-actor lane's -// interceptor never sees it and without this row the coin allowlist entry is inert; and the -// jellyfish path, whose spawn makes its seven fish inside its own graph. The four story-event -// spawners below close the same gap for the event creatures: every one of their BeginDeferred -// sites is bytecode-internal (a CallFunc inside the ubergraph, never ProcessEvent-dispatched), -// so without the row the creature mirrors never exist. Their outputs still pass the allowlist -// gates in the queue branch below (NPC, else world-actor), so no arbitrary EX -// spawn enrolls. -constexpr const wchar_t* kExSpawnSourceClasses[] = { - L"trigger_wispSwarm_C", // the wisps event swarm - L"piramidSpawner_C", // the piramid event chain - L"ticker_wispSpawner_C", // the ambient sky wisps - L"ticker_egSpawner_C", // the ambient night eggs (one eg_C per successful roll) - L"prop_coingun_C", // the sell gun's coin mint - L"jellyfishPath_C", // the space jellyfish's seven, on the world-actor lane - L"trigger_eventer_C", // the scheduler's dispatcher: ventCrawler_C, superEgger_C, - // the ufoDropper set and the other already-allowlisted outputs - L"grayEventController_C", // the graysforest brain's spawn() mints the grayTest_C pack - L"superEgger_C", // the egger's spwn() mints the eggvasion eg_C children - L"tentacleBallsFollower_C", // the follower's runTrigger() mints the tentacleBall_C pack +// The EX_CallMath spawn catch: the spawners whose deferred-spawn output is host-authoritative and +// mirrored, each because its BeginDeferred is bytecode-internal (a CallFunc in its ubergraph, never +// ProcessEvent-dispatched) and no interceptor sees it: the wisps event swarm (up to 32), the piramid +// chain (four killer wisps and a pyramid on the world-actor lane), the ambient sky wisps and night +// eggs (world-anchored, so the host rolls them and a client's ticker is refused in the spawn +// authority), the sell gun's coin mint (the host's own sale and the re-commit of a client's alike; +// without its row the coin allowlist entry is inert), the jellyfish path's seven, and the story-event +// spawners' creatures. Outputs still pass the allowlist gates in the queue branch below (NPC, else +// world-actor), so no arbitrary EX spawn enrolls. `outputs` narrows a source to the outputs whose +// client copies the spawn authority makes inert: the eventer also mints world actors whose BeginPlay +// plays gameplay on a mirror (soltomiaCleaning_C writes the client's ATV and mints a sponge), so they +// stay unmirrored, as before this source, until they have refusal rows. +struct ExSpawnSource { + const wchar_t* cls; + const wchar_t* const* outputs; // null-terminated; null: any allowlisted output +}; +constexpr const wchar_t* kEventerOutputs[] = {L"ventCrawler_C", L"superEgger_C", nullptr}; +constexpr ExSpawnSource kExSpawnSources[] = { + {L"trigger_wispSwarm_C", nullptr}, // the wisps event swarm + {L"piramidSpawner_C", nullptr}, // the piramid event chain + {L"ticker_wispSpawner_C", nullptr}, // the ambient sky wisps + {L"ticker_egSpawner_C", nullptr}, // the ambient night eggs, one eg_C per roll + {L"prop_coingun_C", nullptr}, // the sell gun's coin mint + {L"jellyfishPath_C", nullptr}, // the space jellyfish's seven (world-actor lane) + {L"trigger_eventer_C", kEventerOutputs}, // the scheduler's dispatcher + {L"grayEventController_C", nullptr}, // graysforest: spawn() mints the grayTest_C pack + {L"superEgger_C", nullptr}, // eggvasion: spwn() mints the eg_C children + {L"tentacleBallsFollower_C", nullptr}, // tentacleBalls: runTrigger() mints the pack }; +// Whether `cls`'s own name is one of `names` (null-terminated). One render, then plain compares. +bool NamesOneOf(void* cls, const wchar_t* const* names) { + const std::wstring n = R::ToString(R::NameOf(cls)); + for (const wchar_t* const* o = names; *o; ++o) + if (_wcsicmp(n.c_str(), *o) == 0) return true; + return false; +} + // A source's output may be a world-actor-lane class (the pyramid): the same catch seam, // drained to the world-actor sync's own enrol instead of the NPC one. The same name-equality // walk the world-actor sync uses (allocation-free; each compare renders the name through the @@ -191,14 +198,16 @@ void OnBeginDeferredExSpawn(void* /*context*/, void* srcObj, void* spawned) { if (!coop::npc_sync::IsInstalled()) return; void* srcCls = R::ClassOf(srcObj); if (!srcCls) return; - bool sourceMatch = false; - for (const wchar_t* name : kExSpawnSourceClasses) { - if (R::NameEquals(R::NameOf(srcCls), name)) { sourceMatch = true; break; } - } - if (!sourceMatch) return; + // One render of the source's name, then plain compares: every host deferred spawn reaches here. + const std::wstring srcName = R::ToString(R::NameOf(srcCls)); + const ExSpawnSource* source = nullptr; + for (const ExSpawnSource& e : kExSpawnSources) + if (_wcsicmp(srcName.c_str(), e.cls) == 0) { source = &e; break; } + if (!source) return; void* cls = R::ClassOf(spawned); if (!cls) return; if (!coop::npc_sync::IsAllowlistedClass(cls) && !IsWaAllowlistedClass(cls)) return; + if (source->outputs && !NamesOneOf(cls, source->outputs)) return; const int32_t idx = R::InternalIndexOf(spawned); std::lock_guard lk(g_pendingMx); if (g_pendingExSpawns.size() >= kMaxPendingExSpawns) { @@ -225,7 +234,7 @@ void InstallExSpawnCatch(void* beginDeferredFn) { if (ue_wrap::ufunction_hook::InstallPostHook(beginDeferredFn, &OnBeginDeferredExSpawn)) { UE_LOGI("npc-sync[ex-spawn]: Func-thunk catch installed on BeginDeferred (source-gated: " "trigger_wispSwarm_C -> wisp_C, piramidSpawner_C -> killerwisp_C + piramid2_C, " - "jellyfishPath_C -> jellyfish_C, trigger_eventer_C -> event outputs, " + "jellyfishPath_C -> jellyfish_C, trigger_eventer_C -> ventCrawler_C + superEgger_C, " "grayEventController_C -> grayTest_C, superEgger_C + ticker_egSpawner_C -> eg_C, " "tentacleBallsFollower_C -> tentacleBall_C; EX_CallMath spawns now enroll)"); } else { diff --git a/src/votv-coop/src/coop/world/spawn_authority.cpp b/src/votv-coop/src/coop/world/spawn_authority.cpp index 76ff9ce30..71522ec76 100644 --- a/src/votv-coop/src/coop/world/spawn_authority.cpp +++ b/src/votv-coop/src/coop/world/spawn_authority.cpp @@ -5,23 +5,14 @@ // spawn refused the lifespan reaps it; the yellow-wisp ticker spawns at a navmesh random-walk point, // not around the player, and its product is host-mirrored, so the client must not run its own // spawner; the sky-wisp ticker spawns the sky wisps at absolute map coordinates, so the host rolls -// and clients mirror through the source-gated catch and the variant allowlist; the night-egg -// ticker rolls the same world-anchored shape (a weighted night roll mints one eg_C at random -// absolute map coordinates and writes gamemode->eg), so the host rolls and a client's own tick is refused; -// the roach master's +// and clients mirror through the source-gated catch and the variant allowlist; the roach master's // summon and its three looping timer entries fire independently of its tick. The tick rows: the // insomniac and fossilhound tickers roll inside their tick with no delay chains or reap duties, so // refusing the tick stops the roll and the product, and the products are host-mirrored; the roach // master's tick drives roach movement, the food-eat mutation and crush traces, which a client running // it would diverge, so it and its summoner are refused while the roach sync drives the client // population. The jellyfish path's spawn makes seven fish inside its graph, and the host's are -// mirrored through the source-gated catch, so a client's own, its 18:00 roll's, is refused. The -// event creatures (the vent crawler, the gray pack, the eggs and the tentacle balls) are the same -// shape pushed one level down: on a client they exist only as host mirrors, so their AI, timers, -// overlaps and despawn verbs are refused while the AnimBP and the pose stream stay; and the -// bodies that mint them inside the gray controller, the balls follower and the super egger are -// refused on the client, because their BeginDeferred calls are bytecode-internal and no spawn -// interceptor ever sees them. The eventer's own verbs are event_fire_sync's. +// mirrored through the source-gated catch, so a client's own, its 18:00 roll's, is refused. #include "coop/world/spawn_authority.h" @@ -82,11 +73,12 @@ constexpr Row kRows[] = { {L"cockroachMaster_C", L"ReceiveTick", "cockroachMaster.ReceiveTick"}, {L"ticker_roachSummoner_C", L"ReceiveTick", "roachSummoner.ReceiveTick"}, - // The story-event creatures: on a client every instance is a host mirror -- the local - // spawn paths are suppressed through the allowlist or refused below -- so the gameplay - // entries are refused class-wide. Animation and presentation stay: nothing here touches - // the AnimBP, the timelines simply never arm without their owning entries, and the host's - // pose stream owns the transform. + // The story-event creatures: on a client every instance is a host mirror -- the local spawn + // paths are suppressed through the allowlist or refused below -- so their gameplay entries + // are refused class-wide; the eventer's own verbs are event_fire_sync's. The AnimBP and the + // host's pose stream stay; what a refused BeginPlay also set up is lost on the mirror: the + // crawler's footsteps, vent break and metal-break emitter, a ball's and a gray's mesh + // attach to its spring arm, a ball's ambient loop. // ventCrawler_C: the whole scene is the BeginPlay body -- it binds the prop_vent_C ref, // plays the moveTL timeline (which itself SetActorLocations the crawl), arms the vent-bang // and footstep events, and ends in the door writes, the setEvent flag and K2_DestroyActor @@ -95,21 +87,20 @@ constexpr Row kRows[] = { {L"ventCrawler_C", L"ReceiveBeginPlay","ventCrawler.ReceiveBeginPlay"}, {L"ventCrawler_C", L"BndEvt__ventCrawler_Box_K2Node_ComponentBoundEvent_0_ComponentBeginOverlapSignature__DelegateSignature", "ventCrawler.BoxBeginOverlap"}, - // grayTest_C: BeginPlay re-attaches the mesh, gathers the pack and arms the wander MoveTo - // plus the two looping 3 s timers and the 5-10 s despawn timer (@1117). The sphere overlap - // is the player-catch: on a mainPlayer touch it calls grayController->despawn() and - // deacCams() (@1508). deacCams/disableCams deactivate the client's own cameras, rdrone and - // kerfur -- the damage write, not animation. + // grayTest_C: BeginPlay re-attaches the mesh, waits a second and starts the wander MoveTo + // (@1117); the 3 s loops arm from gatherTeam and step. The sphere overlap is the player + // catch: on a mainPlayer touch it calls grayController->despawn() (@1508). disableCams + // (deacCams's body, @1620) deactivates the client's own cameras, rdrone and kerfur. {L"grayTest_C", L"ReceiveBeginPlay","grayTest.ReceiveBeginPlay"}, {L"grayTest_C", L"BndEvt__grayTest_Sphere_K2Node_ComponentBoundEvent_0_ComponentBeginOverlapSignature__DelegateSignature", "grayTest.SphereBeginOverlap"}, {L"grayTest_C", L"deacCams", "grayTest.deacCams"}, {L"grayTest_C", L"disableCams", "grayTest.disableCams"}, - // eg_C: BeginPlay arms the wander (@1466); the tick bobs the mesh but also rolls the - // camera-distance check into retrieve() and the rendered/setEvent writes (@15) -- refusing - // it costs the bob, while letting it run lets a client egg hop on its own. retrieve() is - // the fly-away despawn: collision off, gravity off, a 10 s delay into gamemode->eg=null - // and setEvent (@1476). OnLanded continues the move chain (@1869). + // eg_C: BeginPlay arms the wander (@1466); the tick bobs the mesh and calls retrieve() once + // player 0's camera is within 5000 (@15) -- refusing it costs the bob, while letting it run + // lets a client egg leave on its own. retrieve() is the fly-away despawn whose Delay loop + // writes rendered and setEvent (@1024, @1476). OnLanded arms check and a 10-300 s + // retrieve (@1869). {L"eg_C", L"ReceiveBeginPlay","eg.ReceiveBeginPlay"}, {L"eg_C", L"ReceiveTick", "eg.ReceiveTick"}, {L"eg_C", L"OnLanded", "eg.OnLanded"}, diff --git a/src/votv-coop/src/ui/dev_menu.cpp b/src/votv-coop/src/ui/dev_menu.cpp index eb2c9b529..6e7b54ff2 100644 --- a/src/votv-coop/src/ui/dev_menu.cpp +++ b/src/votv-coop/src/ui/dev_menu.cpp @@ -231,10 +231,9 @@ void RenderSpawnNpc() { if (ImGui::Button("Spawn kerfurOmega (in front)")) coop::dev::spawn_npc::SpawnKerfurOmega(); ImGui::SameLine(); ImGui::TextDisabled("(host spawns + syncs)"); - // Spawn the allowlisted creatures directly, because the events that would otherwise produce - // one do not reliably leave a catchable actor: the wisp event arms an overlap box, and the - // eventer's ventCrawler spawn goes through EX_CallMath, bypassing our interceptor and landing - // about ten metres away in a vent (see spawn_npc). + // Spawn the allowlisted creatures directly, in front of the player: the wisp event arms an + // overlap box, and the eventer's ventCrawler lands about ten metres away in a vent (see + // spawn_npc). if (ImGui::Button("Spawn killerWisp (in front)")) coop::dev::spawn_npc::SpawnKillerWisp(); ImGui::SameLine(); if (ImGui::Button("Spawn ventCrawler (in front)")) coop::dev::spawn_npc::SpawnVentCrawler(); From aa7a131bddaa94cabfc58293a60c08ff8c2141db Mon Sep 17 00:00:00 2001 From: Shinobu2 Date: Tue, 6 Oct 2026 14:31:50 -0700 Subject: [PATCH 047/306] [coop] a host event fire crosses once, at the eventer verb it ran in The host broadcast a scheduled row only when the scheduler called runEvent, so a fire from the game's own event menu never reached a client, and a prank row crossed as its name while the case the roll picked stayed on the host. The host now emits after the body of both eventer verbs, runEvent and runSpecialEvent, whoever called them, so a prank's chosen case crosses on its own. A client's queued replay is bound to its world and retried each second while its eventer is not up, a dev fire is bound to the session that submitted it, the replay policy has its own file, obelisk is host-only and three lane reasons are corrected. The net of the commits below without their replay-admission layer. Co-Authored-By: Devin SWE-2 Co-Authored-By: Codex Co-Authored-By: Claude (cherry picked from commit e97bcee90bfee0d26c53be3170186d9c7351db17) (cherry picked from commit 934e2c60918a56fd77e890bcd3ffc261da487c70) (cherry picked from commit 27f92572ca87a33b89fb5fb461164681f7166cc1) (cherry picked from commit 88246c09f463e7d2726625296f0da1fe7d8e4319) (cherry picked from commit f15bc67ed414341da0dade37fdb7ad7376aebee0) (cherry picked from commit 48f6248ba7f90d38e1ca80cf21753192700e9cf9) (cherry picked from commit c9f77223e883fd23ae2ee73da66e32c6f1e371a4) (cherry picked from commit e3b14fe2fed153035879a84a8acb2db86ab2e0ea) --- docs/events-and-weather.md | 26 +- .../include/coop/world/event_fire_sync.h | 47 +- src/votv-coop/sources.cmake | 1 + src/votv-coop/src/coop/dev/event_force.cpp | 4 +- src/votv-coop/src/coop/dev/event_trigger.cpp | 9 +- src/votv-coop/src/coop/session/subsystems.cpp | 2 +- .../src/coop/world/event_fire_policy.cpp | 120 +++++ .../src/coop/world/event_fire_sync.cpp | 435 +++++++++++------- .../harness/autotest/autotest_eventforce.cpp | 7 +- 9 files changed, 426 insertions(+), 225 deletions(-) create mode 100644 src/votv-coop/src/coop/world/event_fire_policy.cpp diff --git a/docs/events-and-weather.md b/docs/events-and-weather.md index 77eee2ed7..b183f957d 100644 --- a/docs/events-and-weather.md +++ b/docs/events-and-weather.md @@ -86,19 +86,25 @@ is left alone. ### Story and scheduled events The scheduler is the save's own time step: it walks the event table and runs each due row -through the eventer, all inside Blueprints. The host sees each fire at the verb: a script-body -watch on the eventer's `runEvent`, entered from the save's time step, broadcasts the row -(`coop/world/event_fire_sync`). The client keeps its own copy of the walkable event list empty, -a one-integer write held right before each tick of the clock's cycle, which the game rebuilds -unconditionally at every world load, so its scheduler fires nothing as the host's clock moves it. What a client does with a +through the eventer, all inside Blueprints. The host sees each fire at the verbs: script-body +watches on the eventer's `runEvent` and `runSpecialEvent` broadcast every call whose body ran -- +a scheduled row's, a dev-menu fire's, the game's own menus', and, for the prank roll, the case +`summonArirPrank` actually picked, one broadcast per occurrence (`coop/world/event_fire_sync`). +The client keeps its own copy of the walkable event list empty, a one-integer write held right +before each tick of the clock's cycle, which the game rebuilds unconditionally at every world +load, so its scheduler fires nothing as the host's clock moves it. What a client does with a received fire is a per-row policy kept in the code: rows whose outputs already ride a lane (props, creatures, the ATV, sleep, the wisps, the cues, the devices) are not replayed, because replaying them would deliver the effect twice; the level flips, story flags and cosmetic sounds -no lane carries are replayed through the same native verb; a random prank is never forwarded, -because the client would roll a different one. A replay is skipped when the client's own passed -list already carries the row, unless the host says the event is in flight. The developer menu's -event trigger dispatches through the same host path, so a forced event broadcasts like a -scheduled one. +no lane carries are replayed through the same native verb. A prank's rolled case arrives as its +own `runSpecialEvent` fire and meets the same policy: a graffiti decal or a scare box's arm is +replayed, the outcome the host rolled; a case whose output rides a lane or stays on the host is +not. A replay is skipped when the client's own passed list already carries the row, unless the +host says the event is in flight; one whose world or eventer is not up yet waits and is retried +each second. The developer menu's event trigger dispatches through the same host path, so a +forced event broadcasts like a scheduled one. It refuses running clients, including a join in +progress, and a queued trigger is bound to the submitting session's start serial and world +generation, so restarting the same session object cannot carry an old request into the next run. The game keeps a registry of in-flight events: a reference count and the live event actors, maintained by the actors themselves. The host sees each event begin and end at `lib_C::setEvent` diff --git a/src/votv-coop/include/coop/world/event_fire_sync.h b/src/votv-coop/include/coop/world/event_fire_sync.h index b334109d7..47cffc8a4 100644 --- a/src/votv-coop/include/coop/world/event_fire_sync.h +++ b/src/votv-coop/include/coop/world/event_fire_sync.h @@ -2,10 +2,11 @@ // VOTV's scripted story events (list_events DataTable, 69 rows) fire through saveSlot::settime // -> eventer.runEvent, a BP->BP EX_LocalVirtualFunction chain below both hook seams that only the // script-body gate sees, and level-placed event flips ride no other lane. Three mechanisms carry -// the channel, all bytecode-verified: the HOST watches runEvent through the gate and broadcasts a -// call whose caller is settime, the one call that fires a scheduled row; the CLIENT holds -// saveSlot.allEvents.Num at 0, because settime's walk iterates that array rather than the -// DataTable, and mainGamemode's boot ubergraph rebuilds allEvents FROM that DataTable +// the channel, all bytecode-verified: the HOST watches BOTH eventer verbs through the gate and +// broadcasts each call a body commits -- a scheduled row's runEvent, a dev or menu fire's, and +// the runSpecialEvent the prank roll picks inside summonArirPrank, one emit per occurrence; the +// CLIENT holds saveSlot.allEvents.Num at 0, because settime's walk iterates that array rather +// than the DataTable, and mainGamemode's boot ubergraph rebuilds allEvents FROM that DataTable // unconditionally at every world load, so the zeroed count self-heals and can never poison a // save; and the client REPLAYS the same native verb for allowlisted rows only, which is our own // per-row policy -- it and its reasoning are in docs/events-and-weather.md. @@ -31,30 +32,20 @@ enum class FireKind : uint8_t { SpecialEvent = 1, // trigger_eventer.runSpecialEvent(name) }; -// Cache the session; register the host's watch on runEvent (once per process); and, once the -// daynightCycle_C class has loaded, register the client's hold on the cycle's own tick (once per -// process): right before each tick, allEvents.Num is held at 0. The cycle's settime walks that list -// on every clock change the host's samples make, so without the hold due rows would fire natively -// on the client -- and the first sample after a world load, which the clock lane writes at the same -// tick, could make many due at once. Restored on disconnect; the local world resumes scheduling. -// Called every pump tick by the install fanout. Game thread. -// -// Three classes of fire the watch leaves alone, all deliberate. A trigger-volume fire (bedEvent, -// a scare armed by TBoxActivator) executes per-peer natively when THAT peer overlaps, which is -// per-viewer by the game's own design. The game's internal runSpecialEvent picks have no settime -// caller, so their outputs ride the prop lane. And a dev fire reaches runEvent from our own -// ProcessEvent, not from settime -- the native ui_eventRun likewise -- which is why HostFire -// broadcasts at dispatch, and why the scheduler may still re-fire that row at its scheduled time -// with the client's replayed-set deduping its side. +// Game-thread pump: cache the session, register both host watches, drain pending replays and +// hold the client's allEvents.Num at zero before the cycle tick. This prevents settime from +// firing local scheduled events on a received clock sample. Disconnect restores the list. +// Trigger-volume scares remain per viewer; host menu, scheduler and prank fires emit through +// the same watches. A repeated scheduled row still meets the client's replayed-set dedupe. void Install(coop::net::Session* session); -// The ONE native-fire primitive + the dev broadcast seam. Posts the reflected -// runEvent/runSpecialEvent to the game thread (resolution happens inside the task -- a SOLO -// host's dev menu has no session); when host+connected also broadcasts EventFire{kind,name} (a dev -// fire has no settime caller, so the watch does not see it). specialName crosses ONLY into the -// local native call (RandomPrank = L"ariralPrank"); the wire carries the name alone. Callable from -// any thread (the menu's render thread included). Returns false only when refused (connected as a -// client -- host is authoritative). +// The ONE native-fire primitive. Posts the reflected runEvent/runSpecialEvent to the game thread +// (resolution happens inside the task -- a SOLO host's dev menu has no session); the watches +// emit every host fire to the clients, this call included, so a dev fire broadcasts exactly once +// through the same seam. specialName crosses ONLY into the local native call (RandomPrank = +// L"ariralPrank"); the wire carries the name each verb took -- for a prank roll, the CHOSEN case +// emitted at its own runSpecialEvent. Callable from any thread (the menu's render thread +// included). Returns false when refused as a running client, including while joining. bool HostFire(FireKind kind, const std::wstring& eventName, const std::wstring& specialName); // CLIENT receiver (event_dispatch_world, reliable drain, game thread): replay per policy, or @@ -79,6 +70,10 @@ void OnClientWorldReady(); // replayed-set, drop the session pointer. Game thread. void OnDisconnect(); +// The replay policy itself (event_fire_policy.cpp), module-internal: 1 replay, 0 known +// no-replay (laneOut names the owning lane), -1 unknown -- the default is no-replay. +int ReplayVerdict(const std::string& name, const char** laneOut); + // [dev] How many fires this client has replayed natively, for the event drill. Any thread. unsigned ReplayCount(); diff --git a/src/votv-coop/sources.cmake b/src/votv-coop/sources.cmake index 6c82b0b34..929220903 100644 --- a/src/votv-coop/sources.cmake +++ b/src/votv-coop/sources.cmake @@ -238,6 +238,7 @@ set(VOTVCOOP_SOURCES src/coop/world/firefly_sync.cpp src/coop/world/spawn_authority.cpp src/coop/world/event_cue_sync.cpp + src/coop/world/event_fire_policy.cpp src/coop/world/event_fire_sync.cpp src/coop/world/event_active_sync.cpp src/coop/world/alarm_sync.cpp diff --git a/src/votv-coop/src/coop/dev/event_force.cpp b/src/votv-coop/src/coop/dev/event_force.cpp index 04d1b309b..27c5fe3cf 100644 --- a/src/votv-coop/src/coop/dev/event_force.cpp +++ b/src/votv-coop/src/coop/dev/event_force.cpp @@ -192,8 +192,8 @@ bool ForceNow(const char* eventName) { } const BoxRow* row = RowFor(eventName); if (!row) return false; - // 1) ARM through the shared fire seam (native eventer dispatch + the - // EventFire broadcast -> clients replay the arm per policy). Re-arming an + // 1) ARM through the shared fire seam (native eventer dispatch; the host's + // runEvent watch emits the EventFire -> clients replay the arm per policy). Re-arming an // already-active box is a no-op (isActive=true twice). namespace efs = coop::event_fire_sync; const std::string narrow(row->event); diff --git a/src/votv-coop/src/coop/dev/event_trigger.cpp b/src/votv-coop/src/coop/dev/event_trigger.cpp index b8546db36..64477f116 100644 --- a/src/votv-coop/src/coop/dev/event_trigger.cpp +++ b/src/votv-coop/src/coop/dev/event_trigger.cpp @@ -12,10 +12,11 @@ #include // The eventer resolution and the reflected runEvent/runSpecialEvent dispatch live in -// coop/world/event_fire_sync, which is the single owner: the menu's fire must be the SAME seam that -// broadcasts EventFire to clients (a dev fire is not entered from the scheduler's settime, so the -// host's scheduler watch does not broadcast it). Only the ambient/weather verb table stays here -- those -// are dev-only levers on daynightCycle/mainGamemode timers, never on the wire. +// coop/world/event_fire_sync, which is the single owner: the menu's fire goes through that seam +// so the host's verb watches broadcast it to clients exactly once (a dev fire is entered with no +// Blueprint caller, which the watch reports as the 'dev-call' origin). Only the ambient/weather +// verb table stays here -- those are dev-only levers on daynightCycle/mainGamemode timers, never +// on the wire. namespace coop::dev::event_trigger { namespace { diff --git a/src/votv-coop/src/coop/session/subsystems.cpp b/src/votv-coop/src/coop/session/subsystems.cpp index db5c06ecb..4cfdce42c 100644 --- a/src/votv-coop/src/coop/session/subsystems.cpp +++ b/src/votv-coop/src/coop/session/subsystems.cpp @@ -206,7 +206,7 @@ void Install(coop::net::Session& session) { coop::coingun_sync::Install(&session); // the sell gun + host-minted coins coop::firefly_sync::Install(&session); // peer-symmetric ambient firefly mirror (each peer captures+shares its own) coop::event_cue_sync::Install(&session); // cosmetic emitter cues: the host's runEvent watch sends them, the client replays - coop::event_fire_sync::Install(&session); // scheduled events: the host's runEvent watch (settime caller) -> EventFire; client hold + policy replay + coop::event_fire_sync::Install(&session); // scheduled events: the host's runEvent/runSpecialEvent watches -> EventFire; client hold + policy replay coop::event_active_sync::Install(&session); // the host's setEvent watch: begin/end edges; the join snapshot reads the game's registry coop::alarm_sync::Install(&session); // base radar alarm shared-world toggle (a 1 Hz active poll on both roles) coop::serverbox_sync::Install(&session); // the server boxes' break state: the verbs run on the host, a client's repair sent there diff --git a/src/votv-coop/src/coop/world/event_fire_policy.cpp b/src/votv-coop/src/coop/world/event_fire_policy.cpp new file mode 100644 index 000000000..91da990f3 --- /dev/null +++ b/src/votv-coop/src/coop/world/event_fire_policy.cpp @@ -0,0 +1,120 @@ +// coop/world/event_fire_policy.cpp -- see coop/world/event_fire_sync.h. The replay policy alone +// lives here so the dupe matrix can grow a row at a time free of the sync logic's size budget. + +#include "coop/world/event_fire_sync.h" + +#include + +namespace coop::event_fire_sync { + +// The replay policy, the dupe matrix: every row's concrete output and which lane already +// carries it. The default is no replay. Replay only rows whose effect is a deterministic +// level, save or cosmetic flip that no existing lane delivers; replaying a lane-covered row +// would double-deliver. Keyed by name only: the few names living in both dispatchers have +// the same verdict either way, and the replay call still uses the received dispatch kind. +const char* const kReplayRows[] = { + // Story and save flips (level-placed triggers; no lane): + "treehouse_0", "treehouse_1", "treehouse_2", "treehouse_3", "treehouse_4", "treehouse_5", + "break_RomeoSierra", "break_Victor", "break_Victor2", + // Force-object appends (a save array the client's own dish scan reads; no lane): + "looker_0-1", "looker_1-1", "looker_2-1", "looker_3-1", "looker_4-1", + "arirSignal", "arirSpk", "picSignal", "peace", + "arirSat_0", "arirSat_1", "arirSat_2", "piramid_sig", + // Cosmetic or sound with no lane (the solar row's lights-dark converges with the light lane, + // the same resulting state, echo-suppressed by its last-known prime): + "solar", "call0", + // Trigger-box scare arms, per-viewer scares by design; arming both sides is the correct coop + // semantics (each player gets the scare on their own overlap): + "toeStab", "falseEnter", "mann", "vent", "crys", "fakeGrays", "susArir", + // Graffiti decal specials (a grime decal spawn; no lane): + "arirGraff_0", "arirGraff_1", "arirGraff_2", "arirGraff_3", + "arirGraff_4", "arirGraff_5", "arirGraff_6", +}; + +struct NoReplayRow { const char* name; const char* lane; }; +const NoReplayRow kNoReplayRows[] = { + // Outputs already ride a lane (a replay is a double delivery): + { "starRain", "event_cue lane (cue 0)" }, + { "arirFollower", "npc lane" }, + // The swarm's wisps ride the npc lane (the source-gated catch in npc_world_enum); a replay + // would arm the client's own swarm trigger and double-spawn client-local creatures on top of + // the mirrors: + { "wisps", "npc lane (EX-catch; event-swarm wisp_C mirrored)" }, + // The pyramid's path is host-random (wander and chase timers), so a replay armed the client's + // own trigger box and a client walk-in spawned a divergent client-local pyramid with + // unmirrored wisps. The arrival comes by mirror: the world-actor pose stream, npc-lane wisps + // and the pyramid sync's brain suppression and gather relay. + { "piramid", "piramid mirror lane (WA pose + piramid_sync brain/gather)" }, + // The ship: the trigger-box arm's overlap spawns the ship and the alarm lamp (and possibly + // NPC leaves); replaying the arm would spawn them client-local. Host-only until the ship gets + // a lane: + { "arirShip", "actor spawn on armed overlap (no lane)" }, + // The obelisk's graph is not a bare flag flip: obelisk_C spawns prop_C/prop_obelisk_C actors + // and punches getMainPlayer -- a replay would double-spawn the props client-local and hit the + // client's own player. Host-only until the scene gets a lane: + { "obelisk", "obelisk_C prop spawns + getMainPlayer punch (no lane)" }, + // earthTp fires newsky_C.tp: the black hole, sky and ambience flip plus a 2D sound and + // emails -- the emails already ride the host's append feed, and no lane carries the rest: + { "earthTp", "newsky_C.tp sky/blackhole flip + 2D cue (emails ride the host append feed)" }, + { "vehtp", "atv lane" }, + // bedEvent runs trigger_bedEvent -> bedEvent_C, which moves the bed and teleports + // getMainPlayer on wake; the sleep lane only gates client dreams, it does not carry this: + { "bedEvent", "bedEvent_C bed/player transform + wake teleport (no lane)" }, + { "picnic", "prop lane" }, { "destroyPicnic", "prop lane" }, + { "enasus", "prop lane" }, { "enacros", "prop lane" }, + { "cookier", "prop lane (armed prop)" }, { "paperGray", "prop lane (armed prop)" }, + { "arirEgg", "prop lane (armed prop)" }, + { "console", "device lanes" }, { "lightswitch", "device lanes" }, + { "keypadGuess", "device lanes" }, + // atvExplode writes car.trap -- a host-owned flag no lane carries (atv_condition_sync + // transfers neither trap nor zapped): + { "atvExplode", "car.trap is host-owned (no lane carries trap/zapped)" }, + // Host-local by design: + { "agrav", "physics divergence (by-design host-local)" }, + { "treehouseSleep", "per-player teleport" }, + // Creature and save-actor spawns, host-only until mirrored. The crawler, the egger's eggs, the + // grays and the tentacle balls are mirrored; the eventer's other outputs are not (its catch + // enrols only what a client's copy is kept inert for, npc_world_enum), so they stay host-only: + { "ventCrawler", "npc lane (allowlisted; eventer EX-catch)" }, + { "ventKnocker", "world-actor spawn (no lane yet: the eventer's catch leaves kocker_C out)" }, + { "tentacleBalls", "npc lane (follower EX-catch)" }, + { "morningGay", "world-actor spawn (no lane yet: the eventer's catch leaves morningUfo_C out)" }, + { "borgRozital", "world-actor spawn (no lane yet: the eventer's catch leaves rozitBorg_C out)" }, + { "graysforest", "npc lane (controller EX-catch)" }, + { "graystank", "creature spawn (no lane yet)" }, { "arirBuster", "creature spawn (no lane yet)" }, + { "eggvasion", "npc and world-actor lanes (eventer and egger EX-catch)" }, + { "boarwar", "creature spawn (no lane yet)" }, + { "soltoClean", "world-actor spawn (no lane yet: soltomiaCleaning_C plays gameplay on a mirror)" }, + { "salt", "save-actor spawn (no lane)" }, { "rozitalHole", "save-actor spawn (no lane)" }, + { "dreambase", "save-actor spawn (no lane)" }, + { "fallbody_0", "dropper spawn (no lane yet: the eventer's catch leaves the droppers out)" }, + { "fallbody_1", "dropper spawn (no lane yet: the eventer's catch leaves the droppers out)" }, + { "fallcar_0", "dropper spawn (no lane yet: the eventer's catch leaves the droppers out)" }, + // The prank layer (host-local RNG; thrown-prop outputs ride the prop lane): + { "food", "prank special (prop lane)" }, { "drive", "prank special (prop lane)" }, + { "atvFuel", "prank special (prop lane)" }, { "atvFix", "prank special (prop lane)" }, + { "poisonFood", "prank special (prop lane)" }, { "expDrive", "prank special (prop lane)" }, + { "cookiebox", "prank special (prop lane)" }, { "trashPiles", "prank special (prop lane)" }, + { "vaccine", "prank special (prop lane)" }, { "oil", "prank special (prop lane)" }, + { "begos", "prank special (prop lane)" }, { "gascans", "prank special (prop lane)" }, + { "bombBox", "prank special (prop lane)" }, + { "rockThrow", "prank spawner (no lane)" }, { "hillRoller", "prank spawner (no lane)" }, + { "alienJump", "prank spawner (no lane)" }, { "trashBase", "prank spawner (no lane)" }, + { "alienSounds", "sound gap (future WorldSoundCue)" }, +}; + +// The interaction rows: the row's entire effect is the prank special, host-local RNG. +bool IsPrankRow(const std::string& n) { + return n.rfind("arirInteraction_", 0) == 0; +} + +int ReplayVerdict(const std::string& name, const char** laneOut) { + for (const char* r : kReplayRows) + if (name == r) return 1; + for (const auto& nr : kNoReplayRows) + if (name == nr.name) { *laneOut = nr.lane; return 0; } + if (IsPrankRow(name)) { *laneOut = "prank special (host-local RNG)"; return 0; } + return -1; +} + +} // namespace coop::event_fire_sync diff --git a/src/votv-coop/src/coop/world/event_fire_sync.cpp b/src/votv-coop/src/coop/world/event_fire_sync.cpp index fc8c9b7e0..6f8c17286 100644 --- a/src/votv-coop/src/coop/world/event_fire_sync.cpp +++ b/src/votv-coop/src/coop/world/event_fire_sync.cpp @@ -1,19 +1,21 @@ // coop/world/event_fire_sync.cpp -- see coop/world/event_fire_sync.h. The bytecode facts this // module stands on: the save slot's settime iterates allEvents, skips rows in passEvents, and // on a clock-cross fire calls the eventer's runEvent for the row and appends it to passEvents, -// so a runEvent whose caller is settime is exactly a scheduler fire (the host observation seam), -// and an empty allEvents kills the walk (the client suppression seam); runEvent is the only -// function of that name, called from settime, from the eventer itself and from the game's own -// event menu, which appends nothing; the gamemode's boot marks the rows before a new game's start -// day passed without firing them, and rebuilds allEvents from the events table on every world -// load, so the zeroed count self-heals and a client-written save cannot be poisoned; and the only -// special trigger the table uses is the prank roll, host-local RNG, so the wire carries no -// special field. +// so a runEvent whose caller is settime is exactly a scheduler fire, and an empty allEvents +// kills the walk (the client suppression seam); runEvent is the only function of that name, +// called from settime, from the game's own event menu and through our reflected dispatch; the +// gamemode's boot marks the rows before a new game's start day passed without firing them, and +// rebuilds allEvents from the events table on every world load, so the zeroed count self-heals +// and a client-written save cannot be poisoned; and the only special the table uses is the +// prank roll, which summonArirPrank resolves through an internal runSpecialEvent call -- so the +// watch sits on BOTH eventer verbs and the wire carries the name each call itself took (the +// rolled case, not the prank verb). #include "coop/world/event_fire_sync.h" #include "coop/net/protocol.h" #include "coop/net/session.h" +#include "coop/net/session_serial.h" #include "coop/world/time_sync.h" #include "ue_wrap/core/call.h" @@ -23,10 +25,12 @@ #include "ue_wrap/core/object_index.h" #include "ue_wrap/core/reflection.h" #include "ue_wrap/core/script_gate.h" +#include "ue_wrap/engine/world_identity.h" #include "ue_wrap/world/world_singleton.h" #include "ue_wrap/world/daynightcycle.h" #include +#include #include #include #include @@ -50,16 +54,26 @@ int32_t g_offPassEvents = -1; // saveSlot.passEvents (TArray) int32_t g_offAllEvents = -1; // saveSlot.allEvents (TArray) void* g_runEventFn = nullptr; // runEvent(FName event, FName special) void* g_runSpecialEventFn = nullptr; // runSpecialEvent(FName eventName1) -> bool +void* g_summonArirPrankFn = nullptr; // summonArirPrank(), runSpecialEvent's prank-roll caller void* g_settimeFn = nullptr; // saveSlot.settime, the scheduler's walk int32_t g_offEventParam = -1; // runEvent's `event` in its parameter frame +int32_t g_offSpecialParam = -1; // runSpecialEvent's `eventName1` in its parameter frame bool g_resolved = false; bool g_resolveFailed = false; -// The host's watch on runEvent, registered once per process. +// The host's watches on both eventer verbs, registered once per process. One emit per +// committed call: the watches are the ONLY broadcast source (a dev HostFire's reflected call +// reaches them like any other). There is no occurrence identity on the wire: #N in the log is +// a sequence counter only, a RunEvent dedupes on the client by name within the session, and a +// special is re-fired every broadcast by design. constexpr int kTagEventFire = 0x45564652; // 'EVFR' constexpr const wchar_t* kRunEvent = L"runEvent"; // one pointer: the gate matches a name watch by it +constexpr const wchar_t* kRunSpecialEvent = L"runSpecialEvent"; bool g_watchInstalled = false; bool g_watchLive = false; +bool g_specialWatchInstalled = false; +bool g_specialWatchLive = false; +unsigned g_hostFireSeq = 0; // committed host fires across both verbs (game thread) // Client suppression and replay state, game thread. int g_zeroedAllEventsNum = 0; // what we zeroed (restore on disconnect); 0 = nothing zeroed @@ -72,14 +86,24 @@ struct PendingFire { // passEvents dedupe (a mid-event joiner's blob carries the row as completed history while // the event is still running). bool activeOverride = false; + bool attempted = false; // one replay try ran (paces the next) + std::chrono::steady_clock::time_point lastAttempt{}; }; std::deque g_pending; // replays waiting for the eventer (join window) // EventFire is pre-world-sendable, so a joiner can queue fires for its whole load window. The // cap is the table plus specials plus margin; duplicates are skipped at queue time, so it is // effectively unreachable. constexpr size_t kMaxPending = 128; +// A row whose world or eventer is not up yet is retried on a real-time cadence: the per-tick +// install pump drives the drain, so a waiting row never waits for the next fire. +constexpr auto kReplayRetrySpacing = std::chrono::seconds(1); std::unordered_set g_replayed; // rows replayed this session (dedupe) std::atomic g_replays{0}; // fires replayed natively (ReplayCount) +// The queue and the replayed set belong to one world: unbound while a join window's fires wait +// for the first Gameplay eventer to exist (a fire for the world being loaded must not die with +// the one being left), bound to that world's generation once a replay can run, and dropped when +// the world leaves it -- a connected travel, a load, a quit to menu. +uint32_t g_boundGen = 0; // ue_wrap::world_identity::Generation() stamp; 0 = unbound join window // The UE4 array header; 8-byte FName elements for the two arrays touched. struct RawArray { @@ -88,104 +112,10 @@ struct RawArray { int32_t Max; }; -// The replay policy, the dupe matrix: every row's concrete output and which lane already -// carries it. The default is no replay. Replay only rows whose effect is a deterministic -// level, save or cosmetic flip that no existing lane delivers; replaying a lane-covered row -// would double-deliver. Keyed by name only: the few names living in both dispatchers have -// the same verdict either way, and the replay call still uses the received dispatch kind. -const char* const kReplayRows[] = { - // Story and save flips (level-placed triggers; no lane): - "treehouse_0", "treehouse_1", "treehouse_2", "treehouse_3", "treehouse_4", "treehouse_5", - "break_RomeoSierra", "break_Victor", "break_Victor2", - "obelisk", - // Force-object appends (a save array the client's own dish scan reads; no lane): - "looker_0-1", "looker_1-1", "looker_2-1", "looker_3-1", "looker_4-1", - "arirSignal", "arirSpk", "picSignal", "peace", - "arirSat_0", "arirSat_1", "arirSat_2", "piramid_sig", - // Cosmetic or sound with no lane (the solar row's lights-dark converges with the light lane, - // the same resulting state, echo-suppressed by its last-known prime): - "solar", "call0", - // Trigger-box scare arms, per-viewer scares by design; arming both sides is the correct coop - // semantics (each player gets the scare on their own overlap): - "toeStab", "falseEnter", "mann", "vent", "crys", "fakeGrays", "susArir", - // Graffiti decal specials (a grime decal spawn; no lane): - "arirGraff_0", "arirGraff_1", "arirGraff_2", "arirGraff_3", - "arirGraff_4", "arirGraff_5", "arirGraff_6", -}; - -struct NoReplayRow { const char* name; const char* lane; }; -const NoReplayRow kNoReplayRows[] = { - // Outputs already ride a lane (a replay is a double delivery): - { "starRain", "event_cue lane (cue 0)" }, - { "arirFollower", "npc lane" }, - // The swarm's wisps ride the npc lane (the source-gated catch in npc_world_enum); a replay - // would arm the client's own swarm trigger and double-spawn client-local creatures on top of - // the mirrors: - { "wisps", "npc lane (EX-catch; event-swarm wisp_C mirrored)" }, - // The pyramid's path is host-random (wander and chase timers), so a replay armed the client's - // own trigger box and a client walk-in spawned a divergent client-local pyramid with - // unmirrored wisps. The arrival comes by mirror: the world-actor pose stream, npc-lane wisps - // and the pyramid sync's brain suppression and gather relay. - { "piramid", "piramid mirror lane (WA pose + piramid_sync brain/gather)" }, - // The ship: the trigger-box arm's overlap spawns the ship and the alarm lamp (and possibly - // NPC leaves); replaying the arm would spawn them client-local. Host-only until the ship gets - // a lane: - { "arirShip", "actor spawn on armed overlap (no lane)" }, - { "earthTp", "SELF (pose stream)" }, - { "vehtp", "atv lane" }, - { "bedEvent", "sleep lane" }, - { "picnic", "prop lane" }, { "destroyPicnic", "prop lane" }, - { "enasus", "prop lane" }, { "enacros", "prop lane" }, - { "cookier", "prop lane (armed prop)" }, { "paperGray", "prop lane (armed prop)" }, - { "arirEgg", "prop lane (armed prop)" }, - { "console", "device lanes" }, { "lightswitch", "device lanes" }, - { "keypadGuess", "device lanes" }, { "atvExplode", "atv lane (trap flag)" }, - // Host-local by design: - { "agrav", "physics divergence (by-design host-local)" }, - { "treehouseSleep", "per-player teleport" }, - // Creature and save-actor spawns, host-only until allowlisted. The vent crawler is - // npc-allowlisted, so its no-replay reason is the mirrors: - { "ventCrawler", "npc lane (allowlisted)" }, { "ventKnocker", "creature spawn (no lane yet)" }, - { "tentacleBalls", "creature spawn (no lane yet)" }, { "morningGay", "creature spawn (no lane yet)" }, - { "borgRozital", "creature spawn (no lane yet)" }, { "graysforest", "creature spawn (no lane yet)" }, - { "graystank", "creature spawn (no lane yet)" }, { "arirBuster", "creature spawn (no lane yet)" }, - { "eggvasion", "creature spawn (no lane yet)" }, { "boarwar", "creature spawn (no lane yet)" }, - { "soltoClean", "creature spawn (no lane yet)" }, - { "salt", "save-actor spawn (no lane)" }, { "rozitalHole", "save-actor spawn (no lane)" }, - { "dreambase", "save-actor spawn (no lane)" }, - { "fallbody_0", "dropper spawn (no lane)" }, { "fallbody_1", "dropper spawn (no lane)" }, - { "fallcar_0", "dropper spawn (no lane)" }, - // The prank layer (host-local RNG; thrown-prop outputs ride the prop lane): - { "food", "prank special (prop lane)" }, { "drive", "prank special (prop lane)" }, - { "atvFuel", "prank special (prop lane)" }, { "atvFix", "prank special (prop lane)" }, - { "poisonFood", "prank special (prop lane)" }, { "expDrive", "prank special (prop lane)" }, - { "cookiebox", "prank special (prop lane)" }, { "trashPiles", "prank special (prop lane)" }, - { "vaccine", "prank special (prop lane)" }, { "oil", "prank special (prop lane)" }, - { "begos", "prank special (prop lane)" }, { "gascans", "prank special (prop lane)" }, - { "bombBox", "prank special (prop lane)" }, - { "rockThrow", "prank spawner (no lane)" }, { "hillRoller", "prank spawner (no lane)" }, - { "alienJump", "prank spawner (no lane)" }, { "trashBase", "prank spawner (no lane)" }, - { "alienSounds", "sound gap (future WorldSoundCue)" }, -}; - -// The interaction rows: the row's entire effect is the prank special, host-local RNG. -bool IsPrankRow(const std::string& n) { - return n.rfind("arirInteraction_", 0) == 0; -} - -// The verdict: 1 replay, 0 known no-replay (the lane says why), -1 unknown (default no-replay). -int ReplayVerdict(const std::string& name, const char** laneOut) { - for (const char* r : kReplayRows) - if (name == r) return 1; - for (const auto& nr : kNoReplayRows) - if (name == nr.name) { *laneOut = nr.lane; return 0; } - if (IsPrankRow(name)) { *laneOut = "prank special (host-local RNG)"; return 0; } - return -1; -} - bool MembersMissing() { return g_offSaveSlot < 0 || g_offEventer < 0 || g_offPassEvents < 0 || g_offAllEvents < 0 || - !g_runEventFn || !g_runSpecialEventFn || !g_settimeFn || g_offEventParam < 0; + !g_runEventFn || !g_runSpecialEventFn || !g_settimeFn || g_offEventParam < 0 || + g_offSpecialParam < 0; } bool ResolvePass() { @@ -202,21 +132,28 @@ bool ResolvePass() { if (g_offAllEvents < 0) g_offAllEvents = R::FindPropertyOffset(ssCls, L"allEvents"); g_runEventFn = R::FindFunction(evCls, L"runEvent"); g_runSpecialEventFn = R::FindFunction(evCls, L"runSpecialEvent"); + // The prank roll's frame is an origin label, not a requirement: its absence costs only + // the "prank-roll" tag in the fire log, so it stays out of the latching check. + g_summonArirPrankFn = R::FindFunction(evCls, L"summonArirPrank"); g_settimeFn = R::FindFunction(ssCls, L"settime"); g_offEventParam = g_runEventFn ? R::FindParamOffset(g_runEventFn, L"event") : -1; + g_offSpecialParam = g_runSpecialEventFn ? R::FindParamOffset(g_runSpecialEventFn, L"eventName1") : -1; if (MembersMissing()) { g_resolveFailed = true; UE_LOGW("event_fire: resolution INCOMPLETE on loaded classes (saveSlot=0x%X eventer=0x%X " - "passEvents=0x%X allEvents=0x%X runEvent=%s(event=0x%X) runSpecialEvent=%s settime=%s) -- " + "passEvents=0x%X allEvents=0x%X runEvent=%s(event=0x%X) " + "runSpecialEvent=%s(eventName1=0x%X) settime=%s) -- " "latched OFF; game version mismatch?", g_offSaveSlot, g_offEventer, g_offPassEvents, g_offAllEvents, g_runEventFn ? "yes" : "NO", - g_offEventParam, g_runSpecialEventFn ? "yes" : "NO", g_settimeFn ? "yes" : "NO"); + g_offEventParam, g_runSpecialEventFn ? "yes" : "NO", g_offSpecialParam, + g_settimeFn ? "yes" : "NO"); return false; } g_resolved = true; UE_LOGI("event_fire: resolved (saveSlot=0x%X passEvents=0x%X allEvents=0x%X eventer=0x%X " - "runEvent=yes runSpecialEvent=yes settime=yes)", - g_offSaveSlot, g_offPassEvents, g_offAllEvents, g_offEventer); + "runEvent=yes runSpecialEvent=yes summonArirPrank=%s settime=yes)", + g_offSaveSlot, g_offPassEvents, g_offAllEvents, g_offEventer, + g_summonArirPrankFn ? "yes" : "NO"); return true; } @@ -245,40 +182,49 @@ std::string NarrowName(const R::FName& n) { return s; } -// The native fire, game thread. True iff the verb actually dispatched; callers gate the -// broadcast and the replayed set on it, since a failed host fire must not make clients replay -// an event the authority never executed, and a failed replay must not permanently consume -// the row. +// Whether the host's watch on this verb is live in an enabled gate: a connected host reaches the +// wire only through it. +bool WatchLive(FireKind kind) { + const wchar_t* name = kind == FireKind::SpecialEvent ? kRunSpecialEvent : kRunEvent; + return sg::NameWatchLive(name, kTagEventFire) && sg::IsEnabled(); +} + +// The native fire, game thread. A dispatch that faults is said and not re-run. bool NativeFire(FireKind kind, const std::wstring& eventName, const std::wstring& specialName) { void* eventer = EventerOf(ue_wrap::world_singleton::Gamemode()); if (!eventer) { UE_LOGW("event_fire: no live trigger_eventer -- native fire dropped ('%ls')", eventName.c_str()); return false; } + const char* verb = kind == FireKind::SpecialEvent ? "runSpecialEvent" : "runEvent"; + void* fn = kind == FireKind::SpecialEvent ? g_runSpecialEventFn : g_runEventFn; + if (!fn) { UE_LOGW("event_fire: %s unresolved", verb); return false; } + ue_wrap::ParamFrame f(fn); + if (!f.valid()) return false; + // A missed Set leaves a half-written frame the body reads garbage from: do not dispatch. + const R::FName ev = ue_wrap::fname_utils::StringToFName(eventName); + bool filled; if (kind == FireKind::SpecialEvent) { - if (!g_runSpecialEventFn) { UE_LOGW("event_fire: runSpecialEvent unresolved"); return false; } - ue_wrap::ParamFrame f(g_runSpecialEventFn); - if (!f.valid()) return false; - f.Set(L"eventName1", ue_wrap::fname_utils::StringToFName(eventName)); - if (ue_wrap::Call(eventer, f)) { - UE_LOGI("event_fire: runSpecialEvent('%ls') dispatched", eventName.c_str()); - return true; - } - UE_LOGW("event_fire: runSpecialEvent('%ls') dispatch FAILED", eventName.c_str()); + filled = f.Set(L"eventName1", ev); + } else { + filled = f.Set(L"event", ev) && + f.Set(L"special", ue_wrap::fname_utils::StringToFName(specialName)); + } + if (!filled) { + UE_LOGW("event_fire: %s('%ls') param frame would not fill -- dispatch dropped", + verb, eventName.c_str()); return false; } - if (!g_runEventFn) { UE_LOGW("event_fire: runEvent unresolved"); return false; } - ue_wrap::ParamFrame f(g_runEventFn); - if (!f.valid()) return false; - f.Set(L"event", ue_wrap::fname_utils::StringToFName(eventName)); - f.Set(L"special", ue_wrap::fname_utils::StringToFName(specialName)); - if (ue_wrap::Call(eventer, f)) { + if (!ue_wrap::Call(eventer, f)) { + UE_LOGW("event_fire: %s('%ls') dispatch FAILED", verb, eventName.c_str()); + return false; + } + if (kind == FireKind::SpecialEvent) + UE_LOGI("event_fire: runSpecialEvent('%ls') dispatched", eventName.c_str()); + else UE_LOGI("event_fire: runEvent('%ls', special='%ls') dispatched", eventName.c_str(), specialName.c_str()); - return true; - } - UE_LOGW("event_fire: runEvent('%ls') dispatch FAILED", eventName.c_str()); - return false; + return true; } void Broadcast(FireKind kind, const std::string& name) { @@ -293,19 +239,43 @@ void Broadcast(FireKind kind, const std::string& name) { kind == FireKind::SpecialEvent ? "runSpecialEvent" : "runEvent", name.c_str()); } -// HOST: a scheduler fire, seen as it happens -- runEvent entered from settime, the one call that -// fires a scheduled row. A dev fire reaches runEvent through our own ProcessEvent with no Blueprint -// caller and broadcasts at its own dispatch; the game's event menu calls runEvent with no settime -// either, and appends no row, as before. -sg::Verdict OnRunEventPre(const sg::Call& call) { +// The one emit point for host fires, at the body commit: runEvent entered from settime is a +// scheduler fire, a call with no Blueprint caller and fromOurCode is our own HostFire dispatch, +// and any other caller is the game's own menus -- every one reached clients before only if the +// host broadcast it, which is now this watch's job alone. POST so a cancelled body can never +// announce a fire that did not run; a client replay's own call early-outs on the role gate. +void OnRunEventPost(const sg::Call& call) { auto* s = g_session.load(std::memory_order_acquire); - if (!s || !s->connected() || s->role() != coop::net::Role::Host) return sg::Verdict::Run; - if (!ResolvePass() || call.function != g_runEventFn || call.callerFunction != g_settimeFn) - return sg::Verdict::Run; + if (!s || !s->connected() || s->role() != coop::net::Role::Host) return; + if (!ResolvePass() || call.function != g_runEventFn) return; const std::string name = NarrowName(*reinterpret_cast(call.locals + g_offEventParam)); - UE_LOGI("event_fire: host OBSERVED scheduler fire '%s' (settime -> runEvent)", name.c_str()); + const char* origin = call.callerFunction == g_settimeFn ? "scheduler" : + call.fromOurCode ? "dev-call" : "native"; + ++g_hostFireSeq; + UE_LOGI("event_fire: host fire #%u runEvent('%s') origin=%s -- broadcasting", + g_hostFireSeq, name.c_str(), origin); Broadcast(FireKind::RunEvent, name); - return sg::Verdict::Run; +} + +// HOST: a runSpecialEvent body just completed. The natural entry is a scheduled ariralPrank +// row: runEvent -> summonArirPrank (a rep-tier Array_Random pick, removed from the pool on the +// way out) -> runSpecialEvent, so THIS call is where the rolled outcome becomes known -- the +// row's own broadcast carries only the arirInteraction name. Dev fires (reflected, no caller) +// and the game's cheat menu reach it the same way and emit through here exactly once. +void OnRunSpecialEventPost(const sg::Call& call) { + auto* s = g_session.load(std::memory_order_acquire); + if (!s || !s->connected() || s->role() != coop::net::Role::Host) return; + if (!ResolvePass() || call.function != g_runSpecialEventFn) return; + const std::string name = + NarrowName(*reinterpret_cast(call.locals + g_offSpecialParam)); + const char* origin = + (g_summonArirPrankFn && call.callerFunction == g_summonArirPrankFn) ? "prank-roll" : + call.fromOurCode ? "dev-call" : "native"; + const bool returned = call.result ? *static_cast(call.result) : true; + ++g_hostFireSeq; + UE_LOGI("event_fire: host fire #%u runSpecialEvent('%s') origin=%s returned=%d -- broadcasting", + g_hostFireSeq, name.c_str(), origin, returned ? 1 : 0); + Broadcast(FireKind::SpecialEvent, name); } // True iff the client's own passEvents already contains the row (the transferred save @@ -322,15 +292,34 @@ bool InClientPassEvents(const std::string& name) { return false; } -// The client replay executor, game thread. False if the eventer is not up yet (re-queue). -bool TryReplay(const PendingFire& pf) { - if (!ResolvePass() || !EventerOf(ue_wrap::world_singleton::Gamemode())) return false; +// The replay step's result, game thread: Done pops the row, NotReady waits it (uncounted and paced), +// PermFailed drops it -- the event classes can never resolve on this build. +enum class ReplayStep { Done, NotReady, PermFailed }; + +// A terminal step drops the row outright and says why once, here. +bool DropIfTerminal(ReplayStep step, const PendingFire& pf) { + if (step != ReplayStep::PermFailed) return false; + UE_LOGE("event_fire: replay of '%s' DROPPED -- the event classes never resolved " + "(permanent failure); the row could never dispatch", pf.name.c_str()); + return true; +} + +// The client replay executor, game thread. +ReplayStep TryReplay(const PendingFire& pf) { + if (!ResolvePass() || !EventerOf(ue_wrap::world_singleton::Gamemode())) + return g_resolveFailed ? ReplayStep::PermFailed : ReplayStep::NotReady; + // A replay dispatches only into the CURRENT gameplay world: while it is Unknown or + // another, the singleton lookup can still answer the eventer of the world being left + // (the cached ref fails open on a null current world). + if (ue_wrap::world_identity::CurrentWorldKind() != ue_wrap::world_identity::WorldKind::Gameplay) + return ReplayStep::NotReady; + if (g_boundGen == 0) g_boundGen = ue_wrap::world_identity::Generation(); // Dedupe applies to one-shot scheduled rows only (the game's own passEvents semantics); // specials (graffiti, pranks the menu re-fires) are repeatable by design. if (pf.kind == FireKind::RunEvent) { if (g_replayed.count(pf.name)) { UE_LOGI("event_fire: '%s' already replayed this session -- skipping", pf.name.c_str()); - return true; + return ReplayStep::Done; } // A passEvents hit marks nothing: the skip must stay re-decidable, since marking here would // let a history-skipped fire permanently block a later in-flight override for the same row @@ -339,30 +328,69 @@ bool TryReplay(const PendingFire& pf) { if (!pf.activeOverride && InClientPassEvents(pf.name)) { UE_LOGI("event_fire: '%s' already in local passEvents (save carried it) -- skipping", pf.name.c_str()); - return true; + return ReplayStep::Done; } } const std::wstring w(pf.name.begin(), pf.name.end()); UE_LOGI("event_fire: client REPLAY %s '%s'%s", pf.kind == FireKind::SpecialEvent ? "runSpecialEvent" : "runEvent", pf.name.c_str(), pf.activeOverride ? " (in-flight active-override)" : ""); - // The special is always None: the only native special is the prank roll, host-local RNG, and - // replaying it would roll a different prank here. Marked consumed only on a successful + // The special is always None: the only native special is the prank roll, whose chosen case + // the host's runSpecialEvent watch sends on its own. Marked consumed only on a successful // dispatch (a frame or call failure is loud and must not eat the row). if (NativeFire(pf.kind, w, L"None")) { g_replays.fetch_add(1, std::memory_order_relaxed); if (pf.kind == FireKind::RunEvent) g_replayed.insert(pf.name); } - return true; + return ReplayStep::Done; +} + +// A queued row that could not run yet: stamp the pacing of its next try. +void NoteAttempt(PendingFire& pf) { + pf.attempted = true; + pf.lastAttempt = std::chrono::steady_clock::now(); +} + +// Before a drain and before a new fire is queued: rows and dedupe bound to a world since left +// describe it, not this one, so they close out here. An unbound queue (the join window) has no +// world to be stale in and is left alone, as is the queue through the travel itself -- the +// generation only moves once the new world is observed. +void DropStaleWorldQueue() { + if (g_boundGen == 0) return; + const uint32_t gen = ue_wrap::world_identity::Generation(); + if (gen == g_boundGen) return; + UE_LOGI("event_fire: world changed (gen %u -> %u) -- dropped %zu queued fire(s) and cleared " + "the replayed set (%zu entr(ies)); the new join queue stays unbound", + g_boundGen, gen, g_pending.size(), g_replayed.size()); + g_pending.clear(); + g_replayed.clear(); + g_boundGen = 0; } // CLIENT: replay the queued fires in arrival order, stopping at the first the eventer cannot take -// yet. Run before a new fire is handled, and at the client's world-ready announce, by which time the -// gamemode's boot has set its eventer. +// yet; a front row that already tried waits out its spacing first. Run before a new fire is +// handled, at the client's world-ready announce, and every install tick (the lasting retry). void DrainPending() { + DropStaleWorldQueue(); + const auto now = std::chrono::steady_clock::now(); while (!g_pending.empty()) { - if (!TryReplay(g_pending.front())) return; - g_pending.pop_front(); + PendingFire& front = g_pending.front(); + // A latched resolve failure is permanent -- no dispatch is possible on this build, so + // the row drops outright instead of wedging the FIFO behind a forever NotReady, with no + // spacing to wait out first. + if (front.attempted && !g_resolveFailed && now - front.lastAttempt < kReplayRetrySpacing) + return; + const ReplayStep step = TryReplay(front); + if (step == ReplayStep::Done) { + g_pending.pop_front(); + continue; + } + if (DropIfTerminal(step, front)) { + g_pending.pop_front(); + continue; + } + NoteAttempt(front); + return; } } @@ -408,14 +436,23 @@ void Install(coop::net::Session* session) { // Called every pump tick by the install fanout, which is also the retry until the cycle class // loads and until the gate has resolved the watch's name. if (!g_watchInstalled) - g_watchInstalled = sg::WatchName(kRunEvent, kTagEventFire, &OnRunEventPre, nullptr); - if (g_watchInstalled && !g_watchLive) { + g_watchInstalled = sg::WatchName(kRunEvent, kTagEventFire, nullptr, &OnRunEventPost); + if (!g_specialWatchInstalled) + g_specialWatchInstalled = + sg::WatchName(kRunSpecialEvent, kTagEventFire, nullptr, &OnRunSpecialEventPost); + if ((g_watchInstalled && !g_watchLive) || (g_specialWatchInstalled && !g_specialWatchLive)) sg::ResolvePendingNames(); - if (sg::NameWatchLive(kRunEvent, kTagEventFire)) { - g_watchLive = true; - UE_LOGI("event_fire: the host's scheduler fires are seen at runEvent (a script-gate watch)"); - } + if (!g_watchLive && sg::NameWatchLive(kRunEvent, kTagEventFire)) { + g_watchLive = true; + UE_LOGI("event_fire: the host's fires are seen at runEvent (a script-gate watch)"); } + if (!g_specialWatchLive && sg::NameWatchLive(kRunSpecialEvent, kTagEventFire)) { + g_specialWatchLive = true; + UE_LOGI("event_fire: the host's special picks are seen at runSpecialEvent (a script-gate watch)"); + } + // The client replay queue's lasting retry: a fire whose world or eventer was not up no longer + // waits for the next fire to arrive -- this pump paces it instead. + if (!g_pending.empty()) DrainPending(); namespace DNC = ue_wrap::daynightcycle; if (g_tickObserved || !DNC::EnsureResolved()) return; void* fn = DNC::TickFunction(); @@ -436,26 +473,55 @@ void Install(coop::net::Session* session) { bool HostFire(FireKind kind, const std::wstring& eventName, const std::wstring& specialName) { auto* s = g_session.load(std::memory_order_acquire); - if (s && s->connected() && s->role() != coop::net::Role::Host) { - UE_LOGW("event_fire: HostFire refused -- connected as a client (host is authoritative)"); + if (s && s->running() && s->role() != coop::net::Role::Host) { + UE_LOGW("event_fire: HostFire refused -- running as a client (host is authoritative)"); return false; } + // Session objects can be reused without a world change. Stamp the start as well as the pointer. + const uint32_t gen = ue_wrap::world_identity::Generation(); + const uint32_t serial = coop::net::session_serial::Current(); + const bool wasRunning = s && s->running(); + const bool wasConnectedHost = s && s->connected() && s->role() == coop::net::Role::Host; const std::wstring ev = eventName; const std::wstring sp = specialName.empty() ? L"None" : specialName; - GT::Post([kind, ev, sp] { + GT::Post([kind, ev, sp, s, gen, serial, wasRunning, wasConnectedHost] { + auto* cur = g_session.load(std::memory_order_acquire); + if (cur && cur->running() && cur->role() != coop::net::Role::Host) { + UE_LOGW("event_fire: HostFire('%ls') dropped -- the session is a running CLIENT now", + ev.c_str()); + return; + } + if (cur != s || ue_wrap::world_identity::Generation() != gen || + coop::net::session_serial::Current() != serial) { + UE_LOGW("event_fire: HostFire('%ls') dropped -- the world or the session changed " + "between submit and dispatch", ev.c_str()); + return; + } + // A fire submitted by a starting or connected host dies with that session. + if ((wasRunning && !(cur && cur->running())) || + (wasConnectedHost && !(cur && cur->connected()))) { + UE_LOGW("event_fire: HostFire('%ls') dropped -- the submitting host session ended " + "before dispatch", ev.c_str()); + return; + } // Resolve here, not before the post: a solo host's dev menu has no session. The native fire // warns loudly if the world or the eventer is not up. if (!ResolvePass()) { UE_LOGW("event_fire: HostFire('%ls') -- the event classes are not loaded", ev.c_str()); return; } - if (!NativeFire(kind, ev, sp)) return; // authority did not fire -> nothing to mirror - // The dev seam: a direct runEvent has no settime caller, so the watch leaves it to this - // broadcast. The wire carries the name only, never the special. - std::string narrow; - narrow.reserve(ev.size()); - for (wchar_t c : ev) narrow.push_back((c > 0 && c < 128) ? static_cast(c) : '?'); - Broadcast(kind, narrow); + // A connected host reaches the wire only through the watches, so an unobserved fire + // would run the event and tell nobody -- it is refused rather than waited out. Solo + // keeps the unobserved dispatch path. + if (cur && cur->connected() && !WatchLive(kind)) { + UE_LOGW("event_fire: HostFire('%ls') dropped -- the %s watch is not live (a connected " + "host fires only through it)", ev.c_str(), + kind == FireKind::SpecialEvent ? "runSpecialEvent" : "runEvent"); + return; + } + // No send of its own: the runEvent/runSpecialEvent watches see this reflected call like + // any other host fire and emit it once, after its body (a dev fire is the 'dev-call' origin). + NativeFire(kind, ev, sp); }); return true; } @@ -486,7 +552,12 @@ void OnReliable(const coop::net::EventFirePayload& payload) { } DrainPending(); PendingFire pf{ kind, name }; - if (g_pending.empty() && TryReplay(pf)) return; + if (g_pending.empty()) { // a non-empty queue tries it in FIFO order later + const ReplayStep step = TryReplay(pf); + if (step == ReplayStep::Done) return; + if (DropIfTerminal(step, pf)) return; + NoteAttempt(pf); // the queue must remember the try: it paces the retry + } // One-shot rows dedupe at queue time too (a scheduler re-fire of a dev-fired row during the // same load window would otherwise queue twice; the replay would catch it later, but a // duplicate-free queue keeps the cap honest). @@ -499,7 +570,7 @@ void OnReliable(const coop::net::EventFirePayload& payload) { g_pending.size(), name.c_str()); return; } - UE_LOGI("event_fire: eventer not up yet -- queued '%s' (%zu pending)", + UE_LOGI("event_fire: queued '%s' (%zu pending; the world or eventer is not up yet)", name.c_str(), g_pending.size() + 1); g_pending.push_back(std::move(pf)); } @@ -521,10 +592,15 @@ void ReplayInFlightRow(const std::string& rowName) { } DrainPending(); PendingFire pf{ FireKind::RunEvent, rowName, /*activeOverride=*/true }; - if (g_pending.empty() && TryReplay(pf)) return; - // The eventer is not up yet. If the row is already queued (a fire copy landed in the pre-world - // window), upgrade it in place: two entries would double-dispatch, and the plain copy alone - // could history-skip the in-flight replay. + if (g_pending.empty()) { + const ReplayStep step = TryReplay(pf); + if (step == ReplayStep::Done) return; + if (DropIfTerminal(step, pf)) return; + NoteAttempt(pf); // the queue must remember the try: it paces the retry + } + // The world or eventer is not up yet. If the row is already queued (a fire copy + // landed in the pre-world window), upgrade it in place: two entries would double-dispatch, + // and the plain copy alone could history-skip the in-flight replay. for (auto& q : g_pending) { if (q.kind == FireKind::RunEvent && q.name == rowName) { q.activeOverride = true; @@ -538,7 +614,7 @@ void ReplayInFlightRow(const std::string& rowName) { g_pending.size(), rowName.c_str()); return; } - UE_LOGI("event_fire: eventer not up yet -- queued in-flight '%s' (%zu pending)", + UE_LOGI("event_fire: queued in-flight '%s' (%zu pending)", rowName.c_str(), g_pending.size() + 1); g_pending.push_back(std::move(pf)); } @@ -550,8 +626,8 @@ void OnClientWorldReady() { if (g_pending.empty()) UE_LOGI("event_fire: world ready -- replayed the %zu queued fire(s)", before); else - UE_LOGW("event_fire: world ready but the eventer is not up -- %zu of %zu queued fire(s) wait for " - "the next fire", g_pending.size(), before); + UE_LOGW("event_fire: world ready with %zu of %zu queued fire(s) still pending -- " + "the install pump retries them", g_pending.size(), before); } void OnDisconnect() { @@ -572,6 +648,7 @@ void OnDisconnect() { g_zeroedSaveSlotIdx = -1; g_pending.clear(); g_replayed.clear(); + g_boundGen = 0; g_session.store(nullptr, std::memory_order_release); } diff --git a/src/votv-coop/src/harness/autotest/autotest_eventforce.cpp b/src/votv-coop/src/harness/autotest/autotest_eventforce.cpp index bc047f351..418d91be7 100644 --- a/src/votv-coop/src/harness/autotest/autotest_eventforce.cpp +++ b/src/votv-coop/src/harness/autotest/autotest_eventforce.cpp @@ -4,9 +4,10 @@ // on the canonical row (obelisk): // 1. PRE -- badge snapshot path: RequestRefresh + StatusFor until the box resolves; a fresh // save must read armed=0 shots=1 ([volume-gated] badge state). -// 2. FORCE -- ForceNow("obelisk"): HostFire arm (EventFire broadcast -> the client log -// must show its REPLAY line; obelisk is replay-allowlisted) + the posted overlap -// dispatch with the local pawn (event_force logs "'TB_event_obelisk' FORCED"). +// 2. FORCE -- ForceNow("obelisk"): HostFire arm (the runEvent watch broadcasts -> the client +// log shows its NOT-replayed line; obelisk's prop spawns and player punch are +// host-only) + the posted overlap dispatch with the local pawn (event_force logs +// "'TB_event_obelisk' FORCED"). // 3. POST -- snapshot again: shots must have dropped to 0 ([FIRED] badge state), proving the // native class filter + N decrement + collision-off ran in game bytecode. // Greppable verdict: "eventforce_test: VERDICT PASS|FAIL". From 9de30f66cba1ce662a62036032699c551f04e57a Mon Sep 17 00:00:00 2001 From: pelmentor Date: Sat, 10 Oct 2026 03:14:15 +0600 Subject: [PATCH 048/306] [coop] a client runs no eventer verb but the replay its gate admits The game's event and cheat menus reach runEvent's and runSpecialEvent's bodies on a client, and every spawn inside them mints what no mirror covers. The two watches now refuse a client's own calls in their PRE and admit exactly the call the replay dispatches, by a scope NativeFire holds for its Call; a nested eventer call is refused, since the host sends a prank's rolled case on its own. A watch that settles dead is said and no longer polled, a no-replay line says the reason instead of claiming a lane, the special's out-parameter result is no longer read as a return, and the policy's entry is private. The docs say what a joiner misses. Co-Authored-By: Claude Opus 5.5 --- docs/events-and-weather.md | 9 +- src/votv-coop/include/coop/dev/event_force.h | 2 +- .../include/coop/world/event_fire_sync.h | 4 - .../src/coop/world/event_fire_policy.cpp | 2 +- .../src/coop/world/event_fire_policy.h | 13 +++ .../src/coop/world/event_fire_sync.cpp | 99 +++++++++++++++---- .../harness/autotest/autotest_eventforce.cpp | 2 +- 7 files changed, 103 insertions(+), 28 deletions(-) create mode 100644 src/votv-coop/src/coop/world/event_fire_policy.h diff --git a/docs/events-and-weather.md b/docs/events-and-weather.md index b183f957d..7c6460630 100644 --- a/docs/events-and-weather.md +++ b/docs/events-and-weather.md @@ -99,9 +99,12 @@ replaying them would deliver the effect twice; the level flips, story flags and no lane carries are replayed through the same native verb. A prank's rolled case arrives as its own `runSpecialEvent` fire and meets the same policy: a graffiti decal or a scare box's arm is replayed, the outcome the host rolled; a case whose output rides a lane or stays on the host is -not. A replay is skipped when the client's own passed list already carries the row, unless the -host says the event is in flight; one whose world or eventer is not up yet waits and is retried -each second. The developer menu's event trigger dispatches through the same host path, so a +not. A client runs no eventer verb of its own: the game's event and cheat menus are refused there, +and the one eventer call it runs is the replay's own. A replay is skipped when the client's own +passed list already carries the row, unless the host says the event is in flight; one whose world +or eventer is not up yet waits and is retried each second. A special fired before a peer joins is +not replayed to it: a scare box armed earlier stays unarmed on the joiner, and a decal it spawned +reaches the joiner only if a lane carries it. The developer menu's event trigger dispatches through the same host path, so a forced event broadcasts like a scheduled one. It refuses running clients, including a join in progress, and a queued trigger is bound to the submitting session's start serial and world generation, so restarting the same session object cannot carry an old request into the next run. diff --git a/src/votv-coop/include/coop/dev/event_force.h b/src/votv-coop/include/coop/dev/event_force.h index 2c6314f38..d0c43227a 100644 --- a/src/votv-coop/include/coop/dev/event_force.h +++ b/src/votv-coop/include/coop/dev/event_force.h @@ -38,7 +38,7 @@ void RequestRefresh(); const char* GateNote(const char* eventName); // Arm + complete the event NOW. Arms through the SAME seam as the fire button (HostFire -> the -// native eventer dispatch + the EventFire broadcast, so clients replay the arm per policy), then +// native eventer dispatch, whose runEvent watch broadcasts it; clients replay per policy), then // drives the box's OWN BeginOverlap handler with the local player pawn as OtherActor -- the // native class filter, N bookkeeping and collision-disable all run in the game's own bytecode. // No game state is faked; it is the exact dispatch a real walk-in performs. False if the event diff --git a/src/votv-coop/include/coop/world/event_fire_sync.h b/src/votv-coop/include/coop/world/event_fire_sync.h index 47cffc8a4..2c53c3684 100644 --- a/src/votv-coop/include/coop/world/event_fire_sync.h +++ b/src/votv-coop/include/coop/world/event_fire_sync.h @@ -70,10 +70,6 @@ void OnClientWorldReady(); // replayed-set, drop the session pointer. Game thread. void OnDisconnect(); -// The replay policy itself (event_fire_policy.cpp), module-internal: 1 replay, 0 known -// no-replay (laneOut names the owning lane), -1 unknown -- the default is no-replay. -int ReplayVerdict(const std::string& name, const char** laneOut); - // [dev] How many fires this client has replayed natively, for the event drill. Any thread. unsigned ReplayCount(); diff --git a/src/votv-coop/src/coop/world/event_fire_policy.cpp b/src/votv-coop/src/coop/world/event_fire_policy.cpp index 91da990f3..975d39d32 100644 --- a/src/votv-coop/src/coop/world/event_fire_policy.cpp +++ b/src/votv-coop/src/coop/world/event_fire_policy.cpp @@ -1,7 +1,7 @@ // coop/world/event_fire_policy.cpp -- see coop/world/event_fire_sync.h. The replay policy alone // lives here so the dupe matrix can grow a row at a time free of the sync logic's size budget. -#include "coop/world/event_fire_sync.h" +#include "event_fire_policy.h" #include diff --git a/src/votv-coop/src/coop/world/event_fire_policy.h b/src/votv-coop/src/coop/world/event_fire_policy.h new file mode 100644 index 000000000..34a867e7c --- /dev/null +++ b/src/votv-coop/src/coop/world/event_fire_policy.h @@ -0,0 +1,13 @@ +// coop/world/event_fire_policy.h -- the replay policy's one entry, private to event_fire_sync (a +// co-located src header, not include/): the rows themselves are event_fire_policy.cpp. +#pragma once + +#include + +namespace coop::event_fire_sync { + +// 1 replay, 0 known no-replay (laneOut names who carries the outputs, or why nobody does), -1 +// unknown -- the default is no replay. +int ReplayVerdict(const std::string& name, const char** laneOut); + +} // namespace coop::event_fire_sync diff --git a/src/votv-coop/src/coop/world/event_fire_sync.cpp b/src/votv-coop/src/coop/world/event_fire_sync.cpp index 6f8c17286..3af5f2022 100644 --- a/src/votv-coop/src/coop/world/event_fire_sync.cpp +++ b/src/votv-coop/src/coop/world/event_fire_sync.cpp @@ -13,6 +13,8 @@ #include "coop/world/event_fire_sync.h" +#include "event_fire_policy.h" // ReplayVerdict (co-located private header) + #include "coop/net/protocol.h" #include "coop/net/session.h" #include "coop/net/session_serial.h" @@ -31,6 +33,7 @@ #include #include +#include #include #include #include @@ -71,8 +74,10 @@ constexpr const wchar_t* kRunEvent = L"runEvent"; // one pointer: the gate matc constexpr const wchar_t* kRunSpecialEvent = L"runSpecialEvent"; bool g_watchInstalled = false; bool g_watchLive = false; +bool g_watchDead = false; // settled without going live: said once, no longer polled bool g_specialWatchInstalled = false; bool g_specialWatchLive = false; +bool g_specialWatchDead = false; unsigned g_hostFireSeq = 0; // committed host fires across both verbs (game thread) // Client suppression and replay state, game thread. @@ -189,6 +194,39 @@ bool WatchLive(FireKind kind) { return sg::NameWatchLive(name, kTagEventFire) && sg::IsEnabled(); } +bool IsClientSession() { + auto* s = g_session.load(std::memory_order_acquire); + return s && s->running() && s->role() == coop::net::Role::Client; +} + +// A client runs no eventer verb of its own: the game's event menu and its cheat menu reach +// runEvent's and runSpecialEvent's bodies directly, and every spawn inside them is bytecode-internal, +// so a fire there mints creatures and props no mirror covers. The one eventer call a client runs is +// this module's replay, admitted by NativeFire's own scope: the eventer and the verb it dispatches, +// once. An eventer call nested inside it is refused -- the prank roll's own runSpecialEvent among +// them, whose chosen case the host sends as a fire of its own. +struct Admission { + void* object = nullptr; + void* function = nullptr; + bool spent = false; +}; +Admission* g_admission = nullptr; // game thread; set for exactly the length of NativeFire's Call +uint64_t g_clientRefused = 0; + +sg::Verdict OnEventerVerbPre(const sg::Call& call) { + if (!IsClientSession()) return sg::Verdict::Run; + Admission* a = g_admission; + if (a && !a->spent && call.object == a->object && call.function == a->function) { + a->spent = true; + return sg::Verdict::Run; + } + const uint64_t n = ++g_clientRefused; + if ((n & (n - 1)) == 0) + UE_LOGI("event_fire: refused a client's own eventer call on %p (#%llu) -- the host fires " + "every event", call.object, static_cast(n)); + return sg::Verdict::Cancel; +} + // The native fire, game thread. A dispatch that faults is said and not re-run. bool NativeFire(FireKind kind, const std::wstring& eventName, const std::wstring& specialName) { void* eventer = EventerOf(ue_wrap::world_singleton::Gamemode()); @@ -215,10 +253,23 @@ bool NativeFire(FireKind kind, const std::wstring& eventName, const std::wstring verb, eventName.c_str()); return false; } + Admission adm{eventer, fn}; + struct Scope { + Admission* prev; + explicit Scope(Admission* a) : prev(g_admission) { g_admission = a; } + ~Scope() { g_admission = prev; } + } scope(&adm); if (!ue_wrap::Call(eventer, f)) { UE_LOGW("event_fire: %s('%ls') dispatch FAILED", verb, eventName.c_str()); return false; } + // On a client the gate admits this call and nothing else; a live watch that did not admit it + // refused it, so the body did not run. + if (IsClientSession() && WatchLive(kind) && !adm.spent) { + UE_LOGE("event_fire: %s('%ls') was refused at the gate -- the admission did not match the " + "call; not retried", verb, eventName.c_str()); + return false; + } if (kind == FireKind::SpecialEvent) UE_LOGI("event_fire: runSpecialEvent('%ls') dispatched", eventName.c_str()); else @@ -271,10 +322,9 @@ void OnRunSpecialEventPost(const sg::Call& call) { const char* origin = (g_summonArirPrankFn && call.callerFunction == g_summonArirPrankFn) ? "prank-roll" : call.fromOurCode ? "dev-call" : "native"; - const bool returned = call.result ? *static_cast(call.result) : true; ++g_hostFireSeq; - UE_LOGI("event_fire: host fire #%u runSpecialEvent('%s') origin=%s returned=%d -- broadcasting", - g_hostFireSeq, name.c_str(), origin, returned ? 1 : 0); + UE_LOGI("event_fire: host fire #%u runSpecialEvent('%s') origin=%s -- broadcasting", + g_hostFireSeq, name.c_str(), origin); Broadcast(FireKind::SpecialEvent, name); } @@ -435,20 +485,34 @@ void Install(coop::net::Session* session) { g_session.store(session, std::memory_order_release); // Called every pump tick by the install fanout, which is also the retry until the cycle class // loads and until the gate has resolved the watch's name. + // The PRE is the client's refusal (OnEventerVerbPre), the POST the host's emit. if (!g_watchInstalled) - g_watchInstalled = sg::WatchName(kRunEvent, kTagEventFire, nullptr, &OnRunEventPost); + g_watchInstalled = sg::WatchName(kRunEvent, kTagEventFire, &OnEventerVerbPre, &OnRunEventPost); if (!g_specialWatchInstalled) g_specialWatchInstalled = - sg::WatchName(kRunSpecialEvent, kTagEventFire, nullptr, &OnRunSpecialEventPost); - if ((g_watchInstalled && !g_watchLive) || (g_specialWatchInstalled && !g_specialWatchLive)) - sg::ResolvePendingNames(); - if (!g_watchLive && sg::NameWatchLive(kRunEvent, kTagEventFire)) { - g_watchLive = true; - UE_LOGI("event_fire: the host's fires are seen at runEvent (a script-gate watch)"); + sg::WatchName(kRunSpecialEvent, kTagEventFire, &OnEventerVerbPre, &OnRunSpecialEventPost); + const bool runPending = g_watchInstalled && !g_watchLive && !g_watchDead; + const bool specialPending = g_specialWatchInstalled && !g_specialWatchLive && !g_specialWatchDead; + if (runPending || specialPending) sg::ResolvePendingNames(); + if (runPending) { + if (sg::NameWatchLive(kRunEvent, kTagEventFire)) { + g_watchLive = true; + UE_LOGI("event_fire: the host's fires are seen at runEvent (a script-gate watch)"); + } else if (sg::NameWatchSettled(kRunEvent, kTagEventFire)) { + g_watchDead = true; + UE_LOGE("event_fire: the runEvent watch settled dead -- a host's fires do not cross, and " + "a client's own are not refused, this session"); + } } - if (!g_specialWatchLive && sg::NameWatchLive(kRunSpecialEvent, kTagEventFire)) { - g_specialWatchLive = true; - UE_LOGI("event_fire: the host's special picks are seen at runSpecialEvent (a script-gate watch)"); + if (specialPending) { + if (sg::NameWatchLive(kRunSpecialEvent, kTagEventFire)) { + g_specialWatchLive = true; + UE_LOGI("event_fire: the host's special picks are seen at runSpecialEvent (a script-gate watch)"); + } else if (sg::NameWatchSettled(kRunSpecialEvent, kTagEventFire)) { + g_specialWatchDead = true; + UE_LOGE("event_fire: the runSpecialEvent watch settled dead -- a host's specials do not cross, " + "and a client's own are not refused, this session"); + } } // The client replay queue's lasting retry: a fire whose world or eventer was not up no longer // waits for the next fire to arrive -- this pump paces it instead. @@ -542,7 +606,7 @@ void OnReliable(const coop::net::EventFirePayload& payload) { const char* lane = nullptr; const int verdict = ReplayVerdict(name, &lane); if (verdict == 0) { - UE_LOGI("event_fire: '%s' NOT replayed -- %s owns the outputs", name.c_str(), lane); + UE_LOGI("event_fire: '%s' NOT replayed (%s)", name.c_str(), lane); return; } if (verdict < 0) { @@ -581,8 +645,7 @@ void ReplayInFlightRow(const std::string& rowName) { const char* lane = nullptr; const int verdict = ReplayVerdict(rowName, &lane); if (verdict == 0) { - UE_LOGI("event_fire: in-flight '%s' NOT replayed -- %s owns the outputs (its join " - "snapshot delivers current state)", rowName.c_str(), lane); + UE_LOGI("event_fire: in-flight '%s' NOT replayed (%s)", rowName.c_str(), lane); return; } if (verdict < 0) { @@ -626,8 +689,8 @@ void OnClientWorldReady() { if (g_pending.empty()) UE_LOGI("event_fire: world ready -- replayed the %zu queued fire(s)", before); else - UE_LOGW("event_fire: world ready with %zu of %zu queued fire(s) still pending -- " - "the install pump retries them", g_pending.size(), before); + UE_LOGI("event_fire: world ready with %zu of %zu queued fire(s) still pending -- " + "the install pump retries them each second", g_pending.size(), before); } void OnDisconnect() { diff --git a/src/votv-coop/src/harness/autotest/autotest_eventforce.cpp b/src/votv-coop/src/harness/autotest/autotest_eventforce.cpp index 418d91be7..914497540 100644 --- a/src/votv-coop/src/harness/autotest/autotest_eventforce.cpp +++ b/src/votv-coop/src/harness/autotest/autotest_eventforce.cpp @@ -78,7 +78,7 @@ void RunAutonomousEventForceTest() { const bool pass = post.resolved && pre.shots >= 1 && post.shots == 0; if (pass) UE_LOGI("eventforce_test: VERDICT PASS -- shots %d -> 0 via the native overlap dispatch; " - "expect host 'FORCED' line + client 'REPLAY' line for the obelisk arm", pre.shots); + "expect host 'FORCED' line + client 'NOT replayed' line for the obelisk arm", pre.shots); else UE_LOGW("eventforce_test: VERDICT FAIL -- post shots=%d (expected 0); read the " "event_force lines above for which step broke", post.shots); From 3d5d297222ff1c7c1bbe9a66ef050431551035d1 Mon Sep 17 00:00:00 2001 From: pelmentor Date: Sat, 10 Oct 2026 03:42:48 +0600 Subject: [PATCH 049/306] [dev] an egg drill counts the npc lane's largest population on a client eggvasion mints 121 eggs, each an NPC mirror on a client, the most the npc lane carries at once. egg_drill=1: the host fires it once the client's world is up, the client says its NPC mirror count every 5 s and DONE 30 s after more than 100 exist, so that window's [perf] lines are what the eggs cost the client. Waits on readiness and on the count, never on a clock to reach a milestone. Co-Authored-By: Claude Opus 5.5 --- .../coop/config/config_registry_rows_dev.inc | 5 +++ src/votv-coop/include/coop/dev/event_drill.h | 3 ++ src/votv-coop/src/coop/dev/event_drill.cpp | 42 +++++++++++++++++-- 3 files changed, 47 insertions(+), 3 deletions(-) diff --git a/src/votv-coop/include/coop/config/config_registry_rows_dev.inc b/src/votv-coop/include/coop/config/config_registry_rows_dev.inc index 91512cc80..c1d9e7088 100644 --- a/src/votv-coop/include/coop/config/config_registry_rows_dev.inc +++ b/src/votv-coop/include/coop/config/config_registry_rows_dev.inc @@ -161,6 +161,11 @@ CFG_FLAG(event_drill, "event_drill", "dev", false, "VOTVCOOP_EVENT_DRILL", "snapshot carries it) and then through the scheduler itself (the clock set to day 2 00:18), then " "solar, arirGraff_0 and enasus by the dev fire; the client's DONE line counts the showers and the " "fires it replayed. MUTATES the world (the clock, the events), so it is armed per run.") +CFG_FLAG(egg_drill, "egg_drill", "dev", false, "VOTVCOOP_EGG_DRILL", + "Drill (BOTH peers): once the client's world is up the host fires eggvasion (121 eggs, the " + "npc lane's largest population); the client says how many NPC mirrors it holds every 5 s and " + "DONE 30 s after more than 100 exist, so the run's [perf] lines show what the still eggs cost " + "it. MUTATES the world (an event), so it is armed per run.") CFG_ENUM(command_drill, "command_drill", "dev", "off", "off|on|red|grant|grantred|hostdeny", "VOTVCOOP_COMMAND_DRILL", "Commands drill: once its world is ready, this peer submits /help and an unknown command, then a " "line one byte too long for a request (and, on the host, one of exactly the limit), then (a " diff --git a/src/votv-coop/include/coop/dev/event_drill.h b/src/votv-coop/include/coop/dev/event_drill.h index 8ff11a678..6021d8255 100644 --- a/src/votv-coop/include/coop/dev/event_drill.h +++ b/src/votv-coop/include/coop/dev/event_drill.h @@ -13,6 +13,9 @@ // client's DONE says it replayed both showers and the two fires. The host's save must be before the // second day's 00:17 or it says FAIL, so the drill runs on a New Game (mp.py smoke --host-fresh), the // rig's save being past that hour. Tagged [EVENT-DRILL]; both peers (event_drill=1). +// The egg arm (egg_drill=1, tagged [EGG-DRILL]): the host fires eggvasion once the client's world is +// up, and the client counts its NPC mirrors until 30 s after more than 100 exist, the window whose +// [perf] lines show what the still eggs cost it. #pragma once diff --git a/src/votv-coop/src/coop/dev/event_drill.cpp b/src/votv-coop/src/coop/dev/event_drill.cpp index cd59e71ef..c11305c58 100644 --- a/src/votv-coop/src/coop/dev/event_drill.cpp +++ b/src/votv-coop/src/coop/dev/event_drill.cpp @@ -8,9 +8,13 @@ #include "coop/net/session.h" #include "coop/world/event_cue_sync.h" #include "coop/world/event_fire_sync.h" +#include "coop/element/registry.h" #include "ue_wrap/core/log.h" +#include +#include + namespace coop::dev::event_drill { namespace { @@ -76,13 +80,45 @@ void TickClient() { "solar and arirGraff_0 replayed", showers, fires); } +// The egg arm (egg_drill): eggvasion's 121 eggs are the npc lane's largest population, and a still +// mirror must cost the client nothing once its pose stops changing. Readiness, not a clock: the +// host fires when the client's world is up, the client counts its mirrors and ends 30 s after the +// count first passes 100, so the [perf] lines of that window are the still eggs' cost. +bool g_eggFired = false; +bool g_eggDone = false; +std::chrono::steady_clock::time_point g_eggSeen{}, g_eggNextSay{}; + +void TickEggHost(coop::net::Session& s) { + if (g_eggFired || !s.IsSlotWorldReady(kSlot)) return; + g_eggFired = true; + UE_LOGI("[EGG-DRILL] host: the client's world is up -- the dev fire of eggvasion"); + EF::HostFire(EF::FireKind::RunEvent, L"eggvasion", L"None"); +} + +void TickEggClient() { + if (g_eggDone) return; + const auto now = std::chrono::steady_clock::now(); + if (now < g_eggNextSay) return; + g_eggNextSay = now + std::chrono::seconds(5); + std::vector npcs; + const size_t n = coop::element::Registry::Get().SnapshotActorsByType(coop::element::ElementType::Npc, npcs); + UE_LOGI("[EGG-DRILL] client npc mirrors=%zu", n); + if (n <= 100) return; + if (g_eggSeen == std::chrono::steady_clock::time_point{}) { g_eggSeen = now; return; } + if (now - g_eggSeen < std::chrono::seconds(30)) return; + g_eggDone = true; + UE_LOGI("[EGG-DRILL] client DONE mirrors=%zu -- read this window's [perf] reflected calls", n); +} + } // namespace void Tick(coop::net::Session* session) { static const bool s_on = coop::config::ResolveFlag(::coop::config_registry::rows::event_drill); - if (!s_on || !session || !session->running()) return; - if (session->role() == coop::net::Role::Host) TickHost(*session); - else TickClient(); + static const bool s_eggs = coop::config::ResolveFlag(::coop::config_registry::rows::egg_drill); + if ((!s_on && !s_eggs) || !session || !session->running()) return; + const bool host = session->role() == coop::net::Role::Host; + if (s_on) { if (host) TickHost(*session); else TickClient(); } + if (s_eggs) { if (host) TickEggHost(*session); else TickEggClient(); } } } // namespace coop::dev::event_drill From 714c2167fbfcda22f7f3e1d721669dbfc379312f Mon Sep 17 00:00:00 2001 From: pelmentor Date: Sat, 10 Oct 2026 03:42:48 +0600 Subject: [PATCH 050/306] [coop] an NPC mirror writes its pose only when it differs from the last A mirror re-wrote its actor every frame its interpolation window was open, even when the pose equalled the one on screen: the host's batch rotation re-sends a pose unchanged and each re-send reopened the window. A mirror now remembers what it last wrote and onto which actor, and skips a write that would change nothing; the wisp landing edge is retried on its own each frame until it lands. Measured with egg_drill (121 egg mirrors): about 3% of the dirty ticks were such rewrites; the eggs move, so the rest, about 2,000 writes a second, is their motion, and the client held its 60 fps cap either way. The eventer's output scope is checked before the world-actor list's name renders. Co-Authored-By: Claude Opus 5.5 --- src/votv-coop/include/coop/element/npc.h | 15 +++++++ .../src/coop/creatures/npc_pose_drive.cpp | 39 +++++++++++++++---- .../src/coop/creatures/npc_world_enum.cpp | 3 +- 3 files changed, 49 insertions(+), 8 deletions(-) diff --git a/src/votv-coop/include/coop/element/npc.h b/src/votv-coop/include/coop/element/npc.h index bcb3a6d5f..a5f64cd6c 100644 --- a/src/votv-coop/include/coop/element/npc.h +++ b/src/votv-coop/include/coop/element/npc.h @@ -73,6 +73,21 @@ class Npc : public Element { bool dirty_ = true; // unapplied change to push to the engine bool isWispMirror_ = false; // wisp_C mirror: replay the landing edge (fade-in) bool wispLanded_ = false; // ... one-shot latch (drive succeeded) + + // What ApplyToEngine last wrote, and onto which actor, so a pose identical to it costs + // nothing: the host's rotation re-sends a mirror that lies still (an eggvasion's 121 eggs) the + // same pose, and each re-send reopened the interpolation window and re-teleported the actor + // every frame. A mirror's tick and its CMC's are parked, so nothing else moves it between two + // writes; a rebound actor differs, so it is written. + struct Applied { + void* actor = nullptr; + ue_wrap::FVector pos{}, lookAt{}; + float yaw = 0.f, speed = 0.f, bodyYaw = 0.f; + uint8_t stateBits = 0, kerfState = 0; + bool hasLookAt = false, hasBodyYaw = false, hasKerfState = false, spooky = false; + } applied_; + bool SameAsApplied(void* actor) const; + void RememberApplied(void* actor); }; } // namespace coop::element diff --git a/src/votv-coop/src/coop/creatures/npc_pose_drive.cpp b/src/votv-coop/src/coop/creatures/npc_pose_drive.cpp index d91982d71..9db75541c 100644 --- a/src/votv-coop/src/coop/creatures/npc_pose_drive.cpp +++ b/src/votv-coop/src/coop/creatures/npc_pose_drive.cpp @@ -133,7 +133,38 @@ void Npc::Tick() { if (!actor || !R::IsLiveByIndex(actor, GetInternalIdx())) return; // unbound / GC'd mirror if (!hasPose_) return; // no pose yet -- leave the mirror at its spawn transform AdvanceInterp(); - if (dirty_) { ApplyToEngine(); dirty_ = false; } + if (dirty_) { + dirty_ = false; + if (!SameAsApplied(actor)) { + ApplyToEngine(); + RememberApplied(actor); + } + } + // Wisp mirror: replay the native landing edge (landed=true + dir(true) -> the fade-in the wisp + // gates behind a CMC-tick-computed CurrentFloor read its parked CMC can never produce). + // Grounded-per-host = drive; retried each frame until wisp_C resolves, whether or not the pose + // moved (also covers a joiner mirroring an already-landed wisp: its first pose reads grounded). + if (isWispMirror_ && !wispLanded_ && !(curStateBits_ & coop::net::kStateBitInAir)) + wispLanded_ = ue_wrap::wisp::DriveWispLanding(actor); +} + +bool Npc::SameAsApplied(void* actor) const { + const Applied& a = applied_; + if (a.actor != actor) return false; + if (a.pos.X != curPos_.X || a.pos.Y != curPos_.Y || a.pos.Z != curPos_.Z) return false; + if (a.yaw != curYaw_ || a.speed != curSpeed_ || a.stateBits != curStateBits_) return false; + if (a.hasLookAt != hasLookAt_ || a.hasBodyYaw != hasBodyYaw_ || a.hasKerfState != hasKerfState_) + return false; + if (hasLookAt_ && (a.lookAt.X != curLookAt_.X || a.lookAt.Y != curLookAt_.Y || a.lookAt.Z != curLookAt_.Z)) + return false; + if (hasBodyYaw_ && a.bodyYaw != curBodyYaw_) return false; + if (hasKerfState_ && (a.kerfState != kerfState_ || a.spooky != kerfSpooky_)) return false; + return true; +} + +void Npc::RememberApplied(void* actor) { + applied_ = Applied{actor, curPos_, curLookAt_, curYaw_, curSpeed_, curBodyYaw_, curStateBits_, + kerfState_, hasLookAt_, hasBodyYaw_, hasKerfState_, kerfSpooky_}; } void Npc::ApplyToEngine() { @@ -150,12 +181,6 @@ void Npc::ApplyToEngine() { const ue_wrap::FVector vel{ std::cos(yawRad) * curSpeed_, std::sin(yawRad) * curSpeed_, 0.f }; const bool inAir = (curStateBits_ & coop::net::kStateBitInAir) != 0; Pup::DriveCharacterMovement(actor, vel, inAir); - // Wisp mirror: replay the native landing edge (landed=true + dir(true) -> the - // fade-in the wisp gates behind a CMC-tick-computed CurrentFloor read its parked CMC can - // never produce). Grounded-per-host = drive; retried per frame until wisp_C resolves (also - // covers a joiner mirroring an already-landed wisp: its first pose reads grounded). - if (isWispMirror_ && !wispLanded_ && !inAir) - wispLanded_ = ue_wrap::wisp::DriveWispLanding(actor); // Aim the mirror's head and neck at the host's streamed look target. Writes the kerfur // AnimBP `lookAt` + sets customLookAt=true so the mirror's own BUA stops auto-aiming at the // LOCAL player camera (the desync this fixes). Class-gated inside -> safe no-op on non-kerfur diff --git a/src/votv-coop/src/coop/creatures/npc_world_enum.cpp b/src/votv-coop/src/coop/creatures/npc_world_enum.cpp index 354d4e14c..ca2cfcabf 100644 --- a/src/votv-coop/src/coop/creatures/npc_world_enum.cpp +++ b/src/votv-coop/src/coop/creatures/npc_world_enum.cpp @@ -206,8 +206,9 @@ void OnBeginDeferredExSpawn(void* /*context*/, void* srcObj, void* spawned) { if (!source) return; void* cls = R::ClassOf(spawned); if (!cls) return; - if (!coop::npc_sync::IsAllowlistedClass(cls) && !IsWaAllowlistedClass(cls)) return; + // A scoped source's outputs first: one render, before the world-actor list's per-entry renders. if (source->outputs && !NamesOneOf(cls, source->outputs)) return; + if (!coop::npc_sync::IsAllowlistedClass(cls) && !IsWaAllowlistedClass(cls)) return; const int32_t idx = R::InternalIndexOf(spawned); std::lock_guard lk(g_pendingMx); if (g_pendingExSpawns.size() >= kMaxPendingExSpawns) { From 82092bca152417af2b2aec433e01e7375d2059a1 Mon Sep 17 00:00:00 2001 From: pelmentor Date: Sat, 10 Oct 2026 03:59:58 +0600 Subject: [PATCH 051/306] [coop] the event drill proves a client's own fire is refused The event drill had no arm the refusal could fail: its client now makes its own runEvent, the event menu's call with no admission held, and FAILs unless the gate refuses it. A refused call's log names its verb and event, a host fire through a function the resolve did not latch is said instead of dropped silently, and the world-ready line counts replayed-or-skipped. The dispatch map's event row, the code map, devices.md's pen eviction, a reversed grayTest comment, the trigger's comment and the egg drill's wording are corrected, and IsPrankRow is file-local. Co-Authored-By: Claude Opus 5.5 --- docs/code-map.md | 2 +- docs/coop-dispatch-visibility.md | 2 +- docs/devices.md | 2 +- docs/events-and-weather.md | 2 +- .../coop/config/config_registry_rows_dev.inc | 4 +- src/votv-coop/include/coop/dev/event_drill.h | 16 +++---- .../include/coop/world/event_fire_sync.h | 4 ++ src/votv-coop/src/coop/dev/event_drill.cpp | 11 +++-- src/votv-coop/src/coop/dev/event_trigger.cpp | 4 +- .../src/coop/world/event_fire_policy.cpp | 2 +- .../src/coop/world/event_fire_sync.cpp | 44 ++++++++++++++++--- .../src/coop/world/spawn_authority.cpp | 4 +- 12 files changed, 67 insertions(+), 30 deletions(-) diff --git a/docs/code-map.md b/docs/code-map.md index cd77e9fc0..b10e0d7aa 100644 --- a/docs/code-map.md +++ b/docs/code-map.md @@ -26,7 +26,7 @@ One folder, one concept, named after it. `src/votv-coop/src/` holds the implemen | `coop/props/` | physics props, piles and trash | `prop_lifecycle` (the destroy seam), `prop_snapshot` + `snapshot_census` (the connect snapshot), `remote_prop` with `_spawn`, `_convert`, `_physics`, `_destroy` (the receivers), `host_spawn_watcher`, `prop_key_index`, `prop_synth_key`, `save_identity_bind` + `save_identity_map` (bind save-loaded actors to host eids in the join window), `pile_spawn_bind`, `trash_mirror` (the client's mirror of either trash form, the game's own actor parked), `trash_morph_gate` (the verbs a client refuses so it never authors a transition), `trash_sweep` (the clumps a broom stroke sets rolling: their ids at birth, a carry nobody holds, the roll), `trash_channel`, `trash_grab_intent`, `trash_collect_sync`, `trash_pile_sync`, `trash_use_intercept`, `trash_clump_pose_stream`, `prop_drop_intent`, `prop_spawn_authoring` (did a PLAYER's spawn verb author this birth, or the world's own churn -- the fact the drop-intent seam admits a catalog spawn on), `prop_save_data` (each prop's own save record, by Key), `prop_stick_sync`, `container_contents_sync` + `container_write_policy` (a world container slice, and the host arbitration a client-authored one passes) + `container_park` (the holding pen for a slice whose container has not arrived yet) + `container_birth` (a container that changes hands through the world: the thrower's slice, the host's awaited copy) + `container_view_close` (a player's view into a container ends where the player's reach does), `grab_observer`, `registry_reaper`, `join_membership_sweep`, `unresolved_pose_ledger`, `prop_echo_suppress`, `prop_wire_parity`, `prop_census`, `prop_container_extract`, `prop_destroy_seam`, `prop_fresh_spawn`, `prop_element_tracker`, `prop_sound`, `active_drive` | | `coop/creatures/` | NPCs and creatures | `npc_sync` + `npc_sync_install` (the spawn seam), `npc_mirror`, `npc_adoption`, `npc_pose_host`, `npc_pose_drive`, `npc_world_enum`, `kerfur_convert` with `_host` and `_client`, `kerfur_entity`, `kerfur_reconcile`, `kerfur_prop_adoption`, `kerfur_command`, `kerfur_menu_input`, `kerfur_form_assembler` (the prop-to-NPC conversion cycle), `owner_entity_sync` (creatures whose AI reads the local player: owned per peer, visible to all), `piramid_sync`, `roach_sync`, `wisp_attack_sync`, `wisp_grab_hold`, `wisp_tear_mirror` | | `coop/interactables/` | keyed device state | doors, lights, light groups, containers, garage, appliances, lockers: `interactable_sync` + `interactable_channel`; `keypad_sync` + `keypad_verbs`, `verb_lanes` (the lanes on a device's own verbs at the script gate, wired in one place), `toggle_verbs` (a symmetric device's state at the verb that writes it), `window_sync`, `grime_sync`, `turbine_sync`, `drone_sync`, `device_occupancy`, `garbage_sync`. The signal workstation: `signal_sync`, `signal_catch_sync`, `signal_wire`, `dish_sync`, `dish_calib_sync`, `download_arm_sync` (the download's arm and reset, the host's), `desk_input_sync`, `desk_sim_sync`, `desk_ping_sync` (the ping's verdict, rolled on the host), `desk_verb_intent` and `desk_verb_effects` (the desk's save-family presses, run by the host, their gloss, stat and sounds sent to the presser), `desk_cursor_sync`, `desk_snd_fx`, `deck_play_sync`, `comp_sync` (the refiner's decode, the host's), `console_state_sync`, `drive_sync`, `drive_payload_sync` (a drive's row, the host's), `eraser_press_intent` (the eraser's delete, run by the host), `drive_rack_sync`, `tape_caddy_sync`, `physmods_sync`, `serverbox_sync`, `server_upgrade_sync` (a server box's physical upgrades), `sat_console_sync` and `sat_console_table` (the SAT console's shared commands, run on a terminal the host keeps for each typist, and which command runs where), `laptop_sync`, `portable_pc_lid` (the portable PC's lid, on its own verb), `laptop_buffer_sync`, `floppybox_sync`, `floppy_slot_sync` and `floppy_slot_entry` (a device's disc slot: the state, and which overlap into it counts as an insert), `meadow_db_sync`, `meadow_db_writers`, `meadow_db_join`, `meadow_db_park` and `meadow_db_hash` (the laptop's signal database, the bodies that write it, a joiner's seed of it, its inbound lines that cannot apply yet, and the database as content hashes). The ATV: `atv_sync`, `atv_spawn_park` (a runtime ATV's spawn held until its world is ready), `atv_corrector`, `atv_hit_guard`, `atv_condition_sync` | -| `coop/world/` | global and ambient state | `time_sync`, `sky_sync`, `weather_sync` with `weather_rain`, `weather_fog`, `weather_lightning`, `weather_redsky`, `weather_event_births`, `firefly_sync`, `spawn_authority` (shared-world spawners run on the host only), `event_fire_sync`, `event_active_sync`, `event_cue_sync`, `alarm_sync`, `balance_sync`, `email_sync`, `daily_task_sync`, `power_grid` (the generators run on the host) + `power_decay` (their wear dice) + `power_puzzle` (their repair puzzles) + `power_upgrade` (the upgrades an install spent) + `power_panel` (the panel's one author), `world_actor_sync` + `world_actor_mirror` | +| `coop/world/` | global and ambient state | `time_sync`, `sky_sync`, `weather_sync` with `weather_rain`, `weather_fog`, `weather_lightning`, `weather_redsky`, `weather_event_births`, `firefly_sync`, `spawn_authority` (shared-world spawners run on the host only), `event_fire_sync` + `event_fire_policy` (which received fire a client replays), `event_active_sync`, `event_cue_sync`, `alarm_sync`, `balance_sync`, `email_sync`, `daily_task_sync`, `power_grid` (the generators run on the host) + `power_decay` (their wear dice) + `power_puzzle` (their repair puzzles) + `power_upgrade` (the upgrades an install spent) + `power_panel` (the panel's one author), `world_actor_sync` + `world_actor_mirror` | | `coop/items/` | inventory, the economy, and the deployed items | `player_inventory_sync` + `inventory_wire`, `inventory_pickup_sync`, `order_sync` (shop orders: the client names the row, the host performs and prices it) + `order_queue_sync` (the host's delivery queue mirrored on the clients) + `order_rows` (an order's items on the wire), `coingun_sync` + `coingun_arbiter` + `coingun_collect` (the sell gun and its coins), `save_record_wire`, `hook_sync` + `hook_anchor` + `hook_prop_claim` + `hook_constraint` (the grappling hook and the rope: the owner's stream and the mirrors, the anchor handoff, the props its ties claim, and the tie itself, which lives on the host), `broom_stroke` + `broom_push` (the broom: every stroke is the host's, a client's queued with what it read of its holder, and what a stroke pushes or knocks out of a dispenser streams) | | `coop/comms/` | text chat | `chat_sync`, `chat_feed`, `chat_bubbles`, `chat_log`, `chat_nick_color`, `peer_action_feed` | | `coop/commands/` | commands typed in chat, run on the host | `command_line` (splits a line), `command_targets` (who a word names), `command_registry` (the commands and their nodes), `command_dispatcher` (one line to one verb), `command_args` (an argument word's parsers: a whole number, true or false, a duration), `action_source` (who an admin action is recorded as), `command_sync` (the transport: the chat route, the two wire kinds, the player record), `moderation_commands` (`/kick`, `/ban`, `/banid`, `/unban`, `/tphere`: the handlers call the moderation verbs through ports), `settings_commands` (`/set`, `/reset`: a server setting named by its key, through ports onto the config setters), `mv_commands` (`/mv`: the permission store edited in game, LuckPerms' `/lp` shape), `mv_info` (the lines of `info` and `listgroups`) | diff --git a/docs/coop-dispatch-visibility.md b/docs/coop-dispatch-visibility.md index a6a66525d..ab727658b 100644 --- a/docs/coop-dispatch-visibility.md +++ b/docs/coop-dispatch-visibility.md @@ -176,7 +176,7 @@ Never spawn from an observer directly; post it. `[V]` | the Omega's player-0 reads, which name whom it serves: eight `GetPlayerPawn(0)` (its follow MoveTo and `targetActor`, its patrol's centre, its hand-back, its turn toward the player; murder mode's reach; its hum's pitch) and nine `lib.getMainPlayer()` calls (the disc insert's four, murder mode's three, the petting's, the uncalled `loadHoldItem`'s) | `GetPlayerPawn`: `EX_CallMath` into a native. `getMainPlayer`: `EX_Context` on `Default__lib_C` into a script function, whose body calls `GetPlayerCharacter` from the library's frame | `GetPlayerPawn`: not to the detour, yes to the native post-hook, whose source is the calling Omega. `getMainPlayer`: yes to the script-body gate by class name on `lib_C`, whose caller frame names the Omega and the variable the call writes; a post-hook on its inner `GetPlayerCharacter` sees the library's default object as the source | the host answers the served ones with the puppet of the client whose command the Omega runs (`served_player`, recorded by `coop/creatures/kerfur_command`): every `GetPlayerPawn` read, and of `getMainPlayer` only the insert's, told by the variable it writes, `CallFunc_getMainPlayer_AsMain_Player_1`. The sleep haunting's two `GetPlayerCameraManager(0)` reads and its wake-up punch through `gamemode.mainPlayer`, and the post-keys re-hold's `gamemode.mainPlayer`, pass no seam and stay the host's `[V]` (`kerfur_serve_drill`: a client's follow settles the Omega 1 m from that client, 15 m from the host, in the follow state, where without the `GetPlayerPawn` seam it settles by the host; its `get_reports` leaves the host's held disc in the host's hand) | | the Omega's murder mode: `startKill`, the kill verb's event, which calls `move()` before it sets `kill` | a Blueprint self-call from `actionName` | not to the detour; yes to the script-body gate, by class name | its entry ends a client's service, so the chase's first `move()` reads player 0 (`kerfur_command`) `[V]` (`kerfur_serve_drill`: as the kill verb returns the Omega went for the host, where without the watch it went for the client it served) | | the kerfur's conversion verbs | `EX_LocalVirtualFunction` self-calls, with `EX_CallMath` spawns inside | the verb no; its inner spawns yes, to the native seam; the verb yes to the script-body gate | the script-body gate refuses the verb on a client, which asks the host, and brackets it on the host, whose return converges on the successor the native seam captured `[V]` (`kerfur_convert_drill`: the client's own calls leave both forms standing, the host converts) | -| the scheduler's event fire | a cross-object virtual call | not to the detour or the native seam; yes to the script-body gate | a watch on `runEvent` whose caller is `saveSlot_C::settime` `[V]` | +| an event fire: the eventer's `runEvent` and `runSpecialEvent` | `EX_LocalVirtualFunction` from the scheduler's `settime`, from `summonArirPrank` and from the game's event and cheat menus; ProcessEvent from our own reflected call | not to the detour or the native seam; yes to the script-body gate | two name watches: the host emits at their POST whoever called; a client refuses every call at their PRE but the replay's own, admitted by the scope its dispatch holds `[V]` | | every screen and panel verb | `EX_LocalVirtualFunction` | no | poll the state field `[V]` | | the desk keyboard's key router | widget input | yes, on the occupant's machine only | the desk input lane `[V]` | | the desk ping | not a verb: a latent tick machine gated on a flag | | never write the flag into a mirror `[V]` | diff --git a/docs/devices.md b/docs/devices.md index bc9632812..df46d6144 100644 --- a/docs/devices.md +++ b/docs/devices.md @@ -120,7 +120,7 @@ contents go to every peer, the thrower included; if the two do not fit one slice applied alone and the host's records are dropped, with a warning. A birth slice is taken unjudged but not unbounded: it spends its author's arrival budget like an edit, and past that budget it is held in the pen and replayed rather than refused, since it is the thrower's only copy and is sent -once. The pen holds eight per author, so a ninth simultaneous held slice is lost, said. Each author +once. The pen holds eight per author, so a ninth simultaneous held slice evicts that author's oldest, said. Each author has at most 64 transfers in flight, its own oldest given up past that. The wait ends after thirty seconds or when the thrower leaves. A peer that joins during a transfer gets the container from the fan-out that completes it, not from its seed. A container the host throws publishes its contents at the express, since the diff --git a/docs/events-and-weather.md b/docs/events-and-weather.md index 7c6460630..7ab9111a1 100644 --- a/docs/events-and-weather.md +++ b/docs/events-and-weather.md @@ -87,7 +87,7 @@ is left alone. The scheduler is the save's own time step: it walks the event table and runs each due row through the eventer, all inside Blueprints. The host sees each fire at the verbs: script-body -watches on the eventer's `runEvent` and `runSpecialEvent` broadcast every call whose body ran -- +watches on the eventer's `runEvent` and `runSpecialEvent` broadcast every call whose body completed -- a scheduled row's, a dev-menu fire's, the game's own menus', and, for the prank roll, the case `summonArirPrank` actually picked, one broadcast per occurrence (`coop/world/event_fire_sync`). The client keeps its own copy of the walkable event list empty, a one-integer write held right diff --git a/src/votv-coop/include/coop/config/config_registry_rows_dev.inc b/src/votv-coop/include/coop/config/config_registry_rows_dev.inc index c1d9e7088..51f2766c8 100644 --- a/src/votv-coop/include/coop/config/config_registry_rows_dev.inc +++ b/src/votv-coop/include/coop/config/config_registry_rows_dev.inc @@ -164,8 +164,8 @@ CFG_FLAG(event_drill, "event_drill", "dev", false, "VOTVCOOP_EVENT_DRILL", CFG_FLAG(egg_drill, "egg_drill", "dev", false, "VOTVCOOP_EGG_DRILL", "Drill (BOTH peers): once the client's world is up the host fires eggvasion (121 eggs, the " "npc lane's largest population); the client says how many NPC mirrors it holds every 5 s and " - "DONE 30 s after more than 100 exist, so the run's [perf] lines show what the still eggs cost " - "it. MUTATES the world (an event), so it is armed per run.") + "DONE 30 s after more than 100 exist, so the run's [perf] lines show what the eggs cost it. " + "MUTATES the world (an event), so it is armed per run.") CFG_ENUM(command_drill, "command_drill", "dev", "off", "off|on|red|grant|grantred|hostdeny", "VOTVCOOP_COMMAND_DRILL", "Commands drill: once its world is ready, this peer submits /help and an unknown command, then a " "line one byte too long for a request (and, on the host, one of exactly the limit), then (a " diff --git a/src/votv-coop/include/coop/dev/event_drill.h b/src/votv-coop/include/coop/dev/event_drill.h index 6021d8255..dfa0b855b 100644 --- a/src/votv-coop/include/coop/dev/event_drill.h +++ b/src/votv-coop/include/coop/dev/event_drill.h @@ -4,18 +4,14 @@ // - as the client's transport connects, before its world is up: the dev fire (HostFire, the F1 // menu's seam, through our own ProcessEvent). The cue's send is dropped for the loading slot, so // the shower reaches the joiner through its world-ready join snapshot, as for a mid-shower join; -// - once its world is up: the scheduler itself. The clock is set to the second day at 00:18, a -// minute past the row's 00:17 and the only row due by then, so the cycle's next settime calls -// runEvent('starRain'), which the event lane sees coming from settime and the cue lane as it -// returns. +// - once its world is up: the scheduler itself, the clock set to the second day at 00:18, a minute +// past the row's 00:17 and the only row due by then, so the next settime calls runEvent('starRain'). // Then dev fires the policy decides differently: solar (replayed, a power-down with no lane of its own), // arirGraff_0 (a special, replayed) and enasus (its props ride the prop lane: not replayed). The -// client's DONE says it replayed both showers and the two fires. The host's save must be before the -// second day's 00:17 or it says FAIL, so the drill runs on a New Game (mp.py smoke --host-fresh), the -// rig's save being past that hour. Tagged [EVENT-DRILL]; both peers (event_drill=1). -// The egg arm (egg_drill=1, tagged [EGG-DRILL]): the host fires eggvasion once the client's world is -// up, and the client counts its NPC mirrors until 30 s after more than 100 exist, the window whose -// [perf] lines show what the still eggs cost it. +// client's DONE says it replayed both showers and the two fires and that its own runEvent was refused. +// The host's save must be before the second day's 00:17 or it says FAIL, so the drill runs on a New +// Game (mp.py smoke --host-fresh). Tagged [EVENT-DRILL]; both peers (event_drill=1). The egg arm +// (egg_drill=1, [EGG-DRILL]): the host fires eggvasion and the client counts its NPC mirrors. #pragma once diff --git a/src/votv-coop/include/coop/world/event_fire_sync.h b/src/votv-coop/include/coop/world/event_fire_sync.h index 2c53c3684..86ff79c3c 100644 --- a/src/votv-coop/include/coop/world/event_fire_sync.h +++ b/src/votv-coop/include/coop/world/event_fire_sync.h @@ -73,4 +73,8 @@ void OnDisconnect(); // [dev] How many fires this client has replayed natively, for the event drill. Any thread. unsigned ReplayCount(); +// Dev (the event drill): on a client, dispatch runEvent('solar') the way the game's event menu does, +// holding no admission, and say whether the gate refused it. Game thread; false off a client. +bool DevProbeClientRefusal(); + } // namespace coop::event_fire_sync diff --git a/src/votv-coop/src/coop/dev/event_drill.cpp b/src/votv-coop/src/coop/dev/event_drill.cpp index c11305c58..c00df3ee4 100644 --- a/src/votv-coop/src/coop/dev/event_drill.cpp +++ b/src/votv-coop/src/coop/dev/event_drill.cpp @@ -76,14 +76,19 @@ void TickClient() { const unsigned fires = EF::ReplayCount(); if (showers < 2 || fires < 2) return; g_clientDone = true; + // The refusal arm: the client's own eventer call, the event menu's shape, must be refused. + if (!EF::DevProbeClientRefusal()) { + UE_LOGW("[EVENT-DRILL] client FAIL -- a client's own runEvent was not refused at the gate"); + return; + } UE_LOGI("[EVENT-DRILL] client DONE showers=%u fires=%u -- the join snapshot's shower and the scheduler's; " - "solar and arirGraff_0 replayed", showers, fires); + "solar and arirGraff_0 replayed; a client's own runEvent refused", showers, fires); } // The egg arm (egg_drill): eggvasion's 121 eggs are the npc lane's largest population, and a still -// mirror must cost the client nothing once its pose stops changing. Readiness, not a clock: the +// mirror costs the client a write only when its pose changes. Readiness, not a clock: the // host fires when the client's world is up, the client counts its mirrors and ends 30 s after the -// count first passes 100, so the [perf] lines of that window are the still eggs' cost. +// count first passes 100, so the [perf] lines of that window are the eggs' cost. bool g_eggFired = false; bool g_eggDone = false; std::chrono::steady_clock::time_point g_eggSeen{}, g_eggNextSay{}; diff --git a/src/votv-coop/src/coop/dev/event_trigger.cpp b/src/votv-coop/src/coop/dev/event_trigger.cpp index 64477f116..43ad910c1 100644 --- a/src/votv-coop/src/coop/dev/event_trigger.cpp +++ b/src/votv-coop/src/coop/dev/event_trigger.cpp @@ -200,8 +200,8 @@ bool Trigger(const EventInfo& ev) { const std::string name = ev.name; const Dispatch dispatch = ev.dispatch; if (dispatch != Dispatch::Ambient) { - // The three eventer paths route through the SHARED fire seam: the native dispatch plus the - // EventFire broadcast, so connected clients replay it per policy. + // The three eventer paths route through the SHARED fire seam: the native dispatch, which the + // host's verb watches broadcast, so connected clients replay it per policy. namespace efs = coop::event_fire_sync; const std::wstring wname(name.begin(), name.end()); const bool random = (dispatch == Dispatch::RandomPrank); diff --git a/src/votv-coop/src/coop/world/event_fire_policy.cpp b/src/votv-coop/src/coop/world/event_fire_policy.cpp index 975d39d32..414597037 100644 --- a/src/votv-coop/src/coop/world/event_fire_policy.cpp +++ b/src/votv-coop/src/coop/world/event_fire_policy.cpp @@ -104,7 +104,7 @@ const NoReplayRow kNoReplayRows[] = { }; // The interaction rows: the row's entire effect is the prank special, host-local RNG. -bool IsPrankRow(const std::string& n) { +static bool IsPrankRow(const std::string& n) { return n.rfind("arirInteraction_", 0) == 0; } diff --git a/src/votv-coop/src/coop/world/event_fire_sync.cpp b/src/votv-coop/src/coop/world/event_fire_sync.cpp index 3af5f2022..9f69ad0dd 100644 --- a/src/votv-coop/src/coop/world/event_fire_sync.cpp +++ b/src/votv-coop/src/coop/world/event_fire_sync.cpp @@ -221,9 +221,14 @@ sg::Verdict OnEventerVerbPre(const sg::Call& call) { return sg::Verdict::Run; } const uint64_t n = ++g_clientRefused; - if ((n & (n - 1)) == 0) - UE_LOGI("event_fire: refused a client's own eventer call on %p (#%llu) -- the host fires " - "every event", call.object, static_cast(n)); + if ((n & (n - 1)) == 0) { + const bool special = call.function == g_runSpecialEventFn; + const int32_t off = special ? g_offSpecialParam : g_offEventParam; + const std::string ev = (call.locals && off >= 0) + ? NarrowName(*reinterpret_cast(call.locals + off)) : std::string("?"); + UE_LOGI("event_fire: refused a client's own %s('%s') (#%llu) -- the host fires every event", + special ? "runSpecialEvent" : "runEvent", ev.c_str(), static_cast(n)); + } return sg::Verdict::Cancel; } @@ -278,6 +283,16 @@ bool NativeFire(FireKind kind, const std::wstring& eventName, const std::wstring return true; } +// The resolve latched each verb's function once; a watched call through another function (a +// reloaded eventer class) is not announced, so it is said once rather than lost silently. +void SayUnlatched(const char* verb, void* fn) { + static bool s_said = false; + if (s_said) return; + s_said = true; + UE_LOGW("event_fire: a host %s ran through function %p, not the one resolved -- that fire is NOT " + "broadcast; the eventer class was reloaded?", verb, fn); +} + void Broadcast(FireKind kind, const std::string& name) { auto* s = g_session.load(std::memory_order_acquire); if (!s || !s->connected() || s->role() != coop::net::Role::Host) return; @@ -298,7 +313,8 @@ void Broadcast(FireKind kind, const std::string& name) { void OnRunEventPost(const sg::Call& call) { auto* s = g_session.load(std::memory_order_acquire); if (!s || !s->connected() || s->role() != coop::net::Role::Host) return; - if (!ResolvePass() || call.function != g_runEventFn) return; + if (!ResolvePass()) return; + if (call.function != g_runEventFn) { SayUnlatched("runEvent", call.function); return; } const std::string name = NarrowName(*reinterpret_cast(call.locals + g_offEventParam)); const char* origin = call.callerFunction == g_settimeFn ? "scheduler" : call.fromOurCode ? "dev-call" : "native"; @@ -316,7 +332,8 @@ void OnRunEventPost(const sg::Call& call) { void OnRunSpecialEventPost(const sg::Call& call) { auto* s = g_session.load(std::memory_order_acquire); if (!s || !s->connected() || s->role() != coop::net::Role::Host) return; - if (!ResolvePass() || call.function != g_runSpecialEventFn) return; + if (!ResolvePass()) return; + if (call.function != g_runSpecialEventFn) { SayUnlatched("runSpecialEvent", call.function); return; } const std::string name = NarrowName(*reinterpret_cast(call.locals + g_offSpecialParam)); const char* origin = @@ -687,7 +704,7 @@ void OnClientWorldReady() { const size_t before = g_pending.size(); DrainPending(); if (g_pending.empty()) - UE_LOGI("event_fire: world ready -- replayed the %zu queued fire(s)", before); + UE_LOGI("event_fire: world ready -- the %zu queued fire(s) replayed or skipped", before); else UE_LOGI("event_fire: world ready with %zu of %zu queued fire(s) still pending -- " "the install pump retries them each second", g_pending.size(), before); @@ -715,6 +732,21 @@ void OnDisconnect() { g_session.store(nullptr, std::memory_order_release); } +bool DevProbeClientRefusal() { + if (!GT::IsGameThread() || !IsClientSession() || !ResolvePass()) return false; + void* eventer = EventerOf(ue_wrap::world_singleton::Gamemode()); + if (!eventer || !g_runEventFn) return false; + ue_wrap::ParamFrame f(g_runEventFn); + if (!f.valid() || !f.Set(L"event", ue_wrap::fname_utils::StringToFName(L"solar")) || + !f.Set(L"special", ue_wrap::fname_utils::StringToFName(L"None"))) + return false; + // The client's own call, as the game's event menu makes it: no admission is held, so the gate + // must refuse it. + const uint64_t before = g_clientRefused; + ue_wrap::Call(eventer, f); + return g_clientRefused == before + 1; +} + unsigned ReplayCount() { return g_replays.load(std::memory_order_relaxed); } } // namespace coop::event_fire_sync diff --git a/src/votv-coop/src/coop/world/spawn_authority.cpp b/src/votv-coop/src/coop/world/spawn_authority.cpp index 71522ec76..6724ca430 100644 --- a/src/votv-coop/src/coop/world/spawn_authority.cpp +++ b/src/votv-coop/src/coop/world/spawn_authority.cpp @@ -89,8 +89,8 @@ constexpr Row kRows[] = { "ventCrawler.BoxBeginOverlap"}, // grayTest_C: BeginPlay re-attaches the mesh, waits a second and starts the wander MoveTo // (@1117); the 3 s loops arm from gatherTeam and step. The sphere overlap is the player - // catch: on a mainPlayer touch it calls grayController->despawn() (@1508). disableCams - // (deacCams's body, @1620) deactivates the client's own cameras, rdrone and kerfur. + // catch: on a mainPlayer touch it calls grayController->despawn() (@1508). disableCams is the + // timer entry that calls deacCams (@1620), which deactivates the client's cameras, rdrone and kerfur. {L"grayTest_C", L"ReceiveBeginPlay","grayTest.ReceiveBeginPlay"}, {L"grayTest_C", L"BndEvt__grayTest_Sphere_K2Node_ComponentBoundEvent_0_ComponentBeginOverlapSignature__DelegateSignature", "grayTest.SphereBeginOverlap"}, From 655b0d82ef0b8e59f1295bcb2d0494a577e954d2 Mon Sep 17 00:00:00 2001 From: pelmentor Date: Sat, 10 Oct 2026 04:08:15 +0600 Subject: [PATCH 052/306] [docs] credits: Wigard's second wave, Shinobu2 and Slintchen The ledger names the three authors of this wave of requests: Wigard's row and section gain the ejected disc's record and the desk repaint, and Shinobu2 and Slintchen get a row and a section each. The thanks roll lists both new names under contributors, revision 8. Co-Authored-By: Claude Opus 5.5 --- docs/credits.md | 34 +++++++++++++++++++++++++- src/votv-coop/assets/thanks/thanks.txt | 4 ++- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/docs/credits.md b/docs/credits.md index 993a00710..08b987671 100644 --- a/docs/credits.md +++ b/docs/credits.md @@ -34,7 +34,9 @@ from `git shortlog -sne` and fold each person's identity variants together. | **arigalit** | code · report | ATV seat contention ([#9](https://github.com/VOTV-MP/Multivoid/pull/9)); join-time prop-count divergence; the grappling-hook lane ([#16](https://github.com/VOTV-MP/Multivoid/pull/16)) — four of its decisions are in the shipped lane | 2 commits · 2 co-authored | | **huoyan1231** | code · report | CI and automated builds; the b125 host-log pack | 2 commits · b134 | | **Marlore** | code · report | A nine-symptom pass over the trash carry ([#29](https://github.com/VOTV-MP/Multivoid/pull/29)). Four were real defects and are fixed at the root, co-authored: a refused grab that was never answered, a pile that looked different on every peer (865 of 871 measured), a thrower refused every later grab, and a fallen player who kept carrying | 6 commits · unreleased | -| [**Wigard**](https://github.com/wigarddev) | code | A guest of a sandbox host came up in story ([#32](https://github.com/VOTV-MP/Multivoid/pull/32)) — the transfer header had carried the host's game mode from the start and the host filled it with a constant. Then seven more, each measured red and green before it was sent: a build that did not link on VS 2022 ([#30](https://github.com/VOTV-MP/Multivoid/pull/30)), the shower's mirrored toggle running the wrong verb ([#31](https://github.com/VOTV-MP/Multivoid/pull/31)), a drive left frozen in the port on the far peer ([#33](https://github.com/VOTV-MP/Multivoid/pull/33)), a pile a client bags that the host never learns of ([#34](https://github.com/VOTV-MP/Multivoid/pull/34)), a client's upgrade purchase that charged nobody ([#35](https://github.com/VOTV-MP/Multivoid/pull/35)), a prop whose record changes in place telling no one ([#36](https://github.com/VOTV-MP/Multivoid/pull/36)), the bay window's sponge dabs reaching no other peer ([#37](https://github.com/VOTV-MP/Multivoid/pull/37)), and a client's press of the drone console reaching a drone that cannot fly ([#38](https://github.com/VOTV-MP/Multivoid/pull/38)) | 11 commits · unreleased | +| [**Wigard**](https://github.com/wigarddev) | code | A guest of a sandbox host came up in story ([#32](https://github.com/VOTV-MP/Multivoid/pull/32)) — the transfer header had carried the host's game mode from the start and the host filled it with a constant. Then seven more, each measured red and green before it was sent: a build that did not link on VS 2022 ([#30](https://github.com/VOTV-MP/Multivoid/pull/30)), the shower's mirrored toggle running the wrong verb ([#31](https://github.com/VOTV-MP/Multivoid/pull/31)), a drive left frozen in the port on the far peer ([#33](https://github.com/VOTV-MP/Multivoid/pull/33)), a pile a client bags that the host never learns of ([#34](https://github.com/VOTV-MP/Multivoid/pull/34)), a client's upgrade purchase that charged nobody ([#35](https://github.com/VOTV-MP/Multivoid/pull/35)), a prop whose record changes in place telling no one ([#36](https://github.com/VOTV-MP/Multivoid/pull/36)), the bay window's sponge dabs reaching no other peer ([#37](https://github.com/VOTV-MP/Multivoid/pull/37)), and a client's press of the drone console reaching a drone that cannot fly ([#38](https://github.com/VOTV-MP/Multivoid/pull/38)). Then a disc the host ejects that reached the others blank, with the disc drill that proves it ([#39](https://github.com/VOTV-MP/Multivoid/pull/39)), and a desk repaint whose diagnosis matched a fix main already carried ([#40](https://github.com/VOTV-MP/Multivoid/pull/40)) | 13 commits · unreleased | +| [**Shinobu2**](https://github.com/Shinobu2) | code | About twenty lanes in one request ([#45](https://github.com/VOTV-MP/Multivoid/pull/45)), adopted lane by lane: a newer save record replacing a parked one, a thrown container that kept its contents, damage and fire refused on a peer's puppet with its flame shown, the story-event creatures mirrored, an event fire crossing at the verb it ran in, a client's point sack paid by the host, the server repair's reward, the runtime ATV spawn, a joiner's daily task and nine more | 23 commits · unreleased | +| [**Slintchen**](https://github.com/Slintchen) | code | The request that the mod's UI speak the player's language ([#41](https://github.com/VOTV-MP/Multivoid/pull/41)): its Chinese translations, its system-face merge for CJK and its translator's guide carry into the translation layer being built | in progress | | [**archhn0madd**](https://github.com/archhn0madd) | code | Rejoin without a relaunch — the boot poll answered from the dying world | 1 commit | | **Moddy** | review · design | The architecture and documentation review that became the UE4SS move; the public UE-Modding-Tools pointer that became the blueprint-CFG rung and the migration scanner (patternsleuth); and design published for [Relay](https://github.com/modestimpala/Relay), Moddy's Blueprint networking API for VOTV ([Thunderstore](https://thunderstore.io/c/voices-of-the-void/p/Moddy/Relay/)), in its README and the [README Blueprint](https://blueprintue.com/blueprint/g3s09x9c/) that README links: the watch surface on a Blueprint function, a watch that reads the parameters before the call and can cancel it, and one that only observes after it, which became the script-body gate; the readable join reason and stable diagnostic codes, which became the join screen's named steps and the end-reason codes; the list of cheap edge protections, of which two were missing here: a per-source limit on connections and a private access list on the identity key file; the rule columns that make an actor's own save record its spawn payload, the general form of what this project's prop save-data work was building case by case; the client-only `Quiesce` column, which made this project state its parking rule once and read every park against it; the one paragraph on container handling, which made it write down and measure its own container invariants; the note that a watch on a parent class misses a child's override, which sent us to audit every hook we install, which found three seams that had never installed and two verbs called on the wrong class; and the list of what a returning player's profile covers, which is the list this project's per-player profile now carries | b122 · b143 · 2026-09-02 · b153 · b157 · b160 · b161 · b167 | | **SentientYeet** | review | The substrate critique that re-opened the loader decision | b143 | @@ -151,6 +153,36 @@ Community commits are adopted with their **original authorship preserved** replayed on every peer. And the garage console's keyboard, which pressed a client's own mirrored drone, the one whose flight tick is suppressed. +- **A disc the host ejects reaches the others with its files** ([#39](https://github.com/VOTV-MP/Multivoid/pull/39)), + with the disc drill that measured it red and green: the host's spawn drain now publishes the save record + of a birth it adopts, and a disc another peer holds is refused at a slot the watching peer only mirrors. + Adopted with authorship preserved; the drill now waits on readiness. And a desk repaint ([#40](https://github.com/VOTV-MP/Multivoid/pull/40)) + whose diagnosis matched, exactly, a fix main had landed hours before the request. + +### Shinobu2 +- **About twenty lanes in one request** ([#45](https://github.com/VOTV-MP/Multivoid/pull/45)), several written + with AI tools, each re-derived against the tree it landed on and run on the two-peer rig before it was taken. + Adopted one commit per lane under Shinobu2's name, each carrying the lane's net change: a newer save record + that always replaces the parked one -- whose landing exposed, and so got fixed, a wait of ours that ended + when a record arrived instead of when it landed; a held mirror the tracker knows; no prop destroy deferred to + a load tail on the host; the clock panel reading a game-thread snapshot; a peer puppet's run-ending travel + reviving nobody; a driven prop yielding to a hand at once; the roach scale and the wisp montage's real + parameters; a per-process log for a second game on one install; libopus on the static runtime; a joiner's + daily task; a SAT console line answered in its own context; a falling host birth streaming its pose; a + client's point sack paid by the host; the server repair's reward paid with the fix; the runtime ATV spawn + waiting for a ready world; a thrown container keeping its contents; damage and fire refused on a peer's + puppet and its flame shown; the story-event creatures mirrored; and an event fire crossing once, at the + verb it ran in. One more lane found a real hole whose root was ours, a drive row the host could write + half-way, fixed by us with credit; a few were declined or parked, each with its reason in the reply. + +### Slintchen +- **The mod's UI in the player's language** ([#41](https://github.com/VOTV-MP/Multivoid/pull/41)): a layer + that lets a translator ship a language without a rebuild, with a finished Chinese pack, a system-face merge + so CJK text renders, and a guide for translators. The layer is being rebuilt on gettext catalogues with + contexts and plural forms, and with only the text a player reads wrapped (the request had also wrapped + engine names a reflected call looks up); the translations, the face merge and the guide carry into it under + Slintchen's name. + ### archhn0madd - **Rejoin without a full relaunch** — the fix for SirWilliam's report below. After a quit-to-menu the dying world and its ragdolled `mainPlayer_C` stay in diff --git a/src/votv-coop/assets/thanks/thanks.txt b/src/votv-coop/assets/thanks/thanks.txt index 3fefad08a..4f887e4b2 100644 --- a/src/votv-coop/assets/thanks/thanks.txt +++ b/src/votv-coop/assets/thanks/thanks.txt @@ -16,7 +16,7 @@ ; sentence is the legend, so write titles the way they read in it. Every other line is one ; name, shown as written. A line starting with `;` is a comment. -revision = 7 +revision = 8 title = Multivoid would like to thank [testers] #9EEAB3 @@ -71,6 +71,8 @@ arigalit hediiiqq huoyan1231 Marlore +Shinobu2 +Slintchen Tarangok Wigard From 1e488fef25eb050df28e1ee55caa340a9f6b2c82 Mon Sep 17 00:00:00 2001 From: pelmentor Date: Sat, 10 Oct 2026 04:21:34 +0600 Subject: [PATCH 053/306] [coop] an NPC mirror remembers a pose only when its write took A mirror remembered the pose it tried to write even when the transform write failed, so a still mirror kept a partial pose until it moved; the write now reports, and only a taken one is remembered. The egg drill says FAIL when the fire is refused or no egg reaches the client in a minute, and its window opens once the count stops rising. The refusal probe calls only through a live gate, each eventer verb's unlatched line has its own latch, and three comments say what the code now does. Co-Authored-By: Claude Opus 5.5 --- docs/coop-dispatch-visibility.md | 2 +- src/votv-coop/include/coop/element/npc.h | 8 ++++---- .../include/coop/world/event_fire_sync.h | 5 +++-- .../src/coop/creatures/npc_pose_drive.cpp | 17 ++++++++--------- src/votv-coop/src/coop/dev/event_drill.cpp | 16 ++++++++++++++-- .../src/coop/world/event_fire_sync.cpp | 16 +++++++++------- 6 files changed, 39 insertions(+), 25 deletions(-) diff --git a/docs/coop-dispatch-visibility.md b/docs/coop-dispatch-visibility.md index ab727658b..d956ce1ca 100644 --- a/docs/coop-dispatch-visibility.md +++ b/docs/coop-dispatch-visibility.md @@ -176,7 +176,7 @@ Never spawn from an observer directly; post it. `[V]` | the Omega's player-0 reads, which name whom it serves: eight `GetPlayerPawn(0)` (its follow MoveTo and `targetActor`, its patrol's centre, its hand-back, its turn toward the player; murder mode's reach; its hum's pitch) and nine `lib.getMainPlayer()` calls (the disc insert's four, murder mode's three, the petting's, the uncalled `loadHoldItem`'s) | `GetPlayerPawn`: `EX_CallMath` into a native. `getMainPlayer`: `EX_Context` on `Default__lib_C` into a script function, whose body calls `GetPlayerCharacter` from the library's frame | `GetPlayerPawn`: not to the detour, yes to the native post-hook, whose source is the calling Omega. `getMainPlayer`: yes to the script-body gate by class name on `lib_C`, whose caller frame names the Omega and the variable the call writes; a post-hook on its inner `GetPlayerCharacter` sees the library's default object as the source | the host answers the served ones with the puppet of the client whose command the Omega runs (`served_player`, recorded by `coop/creatures/kerfur_command`): every `GetPlayerPawn` read, and of `getMainPlayer` only the insert's, told by the variable it writes, `CallFunc_getMainPlayer_AsMain_Player_1`. The sleep haunting's two `GetPlayerCameraManager(0)` reads and its wake-up punch through `gamemode.mainPlayer`, and the post-keys re-hold's `gamemode.mainPlayer`, pass no seam and stay the host's `[V]` (`kerfur_serve_drill`: a client's follow settles the Omega 1 m from that client, 15 m from the host, in the follow state, where without the `GetPlayerPawn` seam it settles by the host; its `get_reports` leaves the host's held disc in the host's hand) | | the Omega's murder mode: `startKill`, the kill verb's event, which calls `move()` before it sets `kill` | a Blueprint self-call from `actionName` | not to the detour; yes to the script-body gate, by class name | its entry ends a client's service, so the chase's first `move()` reads player 0 (`kerfur_command`) `[V]` (`kerfur_serve_drill`: as the kill verb returns the Omega went for the host, where without the watch it went for the client it served) | | the kerfur's conversion verbs | `EX_LocalVirtualFunction` self-calls, with `EX_CallMath` spawns inside | the verb no; its inner spawns yes, to the native seam; the verb yes to the script-body gate | the script-body gate refuses the verb on a client, which asks the host, and brackets it on the host, whose return converges on the successor the native seam captured `[V]` (`kerfur_convert_drill`: the client's own calls leave both forms standing, the host converts) | -| an event fire: the eventer's `runEvent` and `runSpecialEvent` | `EX_LocalVirtualFunction` from the scheduler's `settime`, from `summonArirPrank` and from the game's event and cheat menus; ProcessEvent from our own reflected call | not to the detour or the native seam; yes to the script-body gate | two name watches: the host emits at their POST whoever called; a client refuses every call at their PRE but the replay's own, admitted by the scope its dispatch holds `[V]` | +| an event fire: the eventer's `runEvent` and `runSpecialEvent` | `EX_LocalVirtualFunction` from the scheduler's `settime`, from `summonArirPrank` and from the game's event and cheat menus; ProcessEvent from our own reflected call | not to the detour or the native seam; yes to the script-body gate | two name watches: the host emits at their POST whoever called; a client refuses every call at their PRE but the replay's own, admitted by the scope its dispatch holds; the callers are read from the bytecode `[RD]`, the watches measured live `[V]` | | every screen and panel verb | `EX_LocalVirtualFunction` | no | poll the state field `[V]` | | the desk keyboard's key router | widget input | yes, on the occupant's machine only | the desk input lane `[V]` | | the desk ping | not a verb: a latent tick machine gated on a flag | | never write the flag into a mirror `[V]` | diff --git a/src/votv-coop/include/coop/element/npc.h b/src/votv-coop/include/coop/element/npc.h index a5f64cd6c..f691406a7 100644 --- a/src/votv-coop/include/coop/element/npc.h +++ b/src/votv-coop/include/coop/element/npc.h @@ -35,9 +35,9 @@ class Npc : public Element { // new pose (or snaps on the first packet / a teleport). Game thread only. void SetTargetNpcPose(const coop::net::EntityPoseSnapshot& snap); - // CLIENT mirror: every frame -- advance the interp + push the pose to the engine (skips the - // engine write when frozen at target between packets). No-op until a pose arrives + the actor - // is live. Game thread only. + // CLIENT mirror: every frame -- advance the interp, and push the pose to the engine when it + // differs from what was last written there. No-op until a pose arrives + the actor is live. + // Game thread only. void Tick(); // Wisp mirror: the wisp_C fade-in fires at a tick-driven landing edge a CMC-parked mirror can @@ -48,7 +48,7 @@ class Npc : public Element { private: void AdvanceInterp(); // advance the open window to now (mirrors RemotePlayer::AdvanceInterp) - void ApplyToEngine(); // SetActorLocation + SetActorRotation + DriveCharacterMovement + bool ApplyToEngine(); // SetActorLocation + SetActorRotation + DriveCharacterMovement; false if a transform write failed // Receiver-side interpolation state (game thread only; the engine path is single-threaded). ue_wrap::FVector curPos_{}; diff --git a/src/votv-coop/include/coop/world/event_fire_sync.h b/src/votv-coop/include/coop/world/event_fire_sync.h index 86ff79c3c..00d6c3aea 100644 --- a/src/votv-coop/include/coop/world/event_fire_sync.h +++ b/src/votv-coop/include/coop/world/event_fire_sync.h @@ -73,8 +73,9 @@ void OnDisconnect(); // [dev] How many fires this client has replayed natively, for the event drill. Any thread. unsigned ReplayCount(); -// Dev (the event drill): on a client, dispatch runEvent('solar') the way the game's event menu does, -// holding no admission, and say whether the gate refused it. Game thread; false off a client. +// Dev (the event drill): on a client, dispatch runEvent('solar') as a reflected call holding no +// admission, and say whether the gate refused it. Game thread; false off a client or with the +// watch not live (the call is then not made). bool DevProbeClientRefusal(); } // namespace coop::event_fire_sync diff --git a/src/votv-coop/src/coop/creatures/npc_pose_drive.cpp b/src/votv-coop/src/coop/creatures/npc_pose_drive.cpp index 9db75541c..b106c21e2 100644 --- a/src/votv-coop/src/coop/creatures/npc_pose_drive.cpp +++ b/src/votv-coop/src/coop/creatures/npc_pose_drive.cpp @@ -135,10 +135,8 @@ void Npc::Tick() { AdvanceInterp(); if (dirty_) { dirty_ = false; - if (!SameAsApplied(actor)) { - ApplyToEngine(); - RememberApplied(actor); - } + // Remembered only when the transform write took, so a failed write is tried again. + if (!SameAsApplied(actor) && ApplyToEngine()) RememberApplied(actor); } // Wisp mirror: replay the native landing edge (landed=true + dir(true) -> the fade-in the wisp // gates behind a CMC-tick-computed CurrentFloor read its parked CMC can never produce). @@ -167,13 +165,13 @@ void Npc::RememberApplied(void* actor) { kerfState_, hasLookAt_, hasBodyYaw_, hasKerfState_, kerfSpooky_}; } -void Npc::ApplyToEngine() { +bool Npc::ApplyToEngine() { void* actor = GetActor(); - if (!actor) return; + if (!actor) return false; // NPC capsule centre IS the actor pivot (the host streams the actor's location, the mirror is the // same class spawned at the same place) -- no mesh-offset reconstruction (unlike RemotePlayer). - E::SetActorLocation(actor, curPos_); - E::SetActorRotation(actor, ue_wrap::FRotator{0.f, curYaw_, 0.f}); + const bool moved = E::SetActorLocation(actor, curPos_); + const bool turned = E::SetActorRotation(actor, ue_wrap::FRotator{0.f, curYaw_, 0.f}); // Drive the mirror's OWN CMC so its AnimBP reads the right Velocity + MovementMode (the native // locomotion path -- the same fields the host NPC's possessed CMC carries). Reconstruct planar // velocity from the streamed body-yaw + speed magnitude. @@ -184,7 +182,7 @@ void Npc::ApplyToEngine() { // Aim the mirror's head and neck at the host's streamed look target. Writes the kerfur // AnimBP `lookAt` + sets customLookAt=true so the mirror's own BUA stops auto-aiming at the // LOCAL player camera (the desync this fixes). Class-gated inside -> safe no-op on non-kerfur - // NPCs. Re-asserted each drive so a moving gaze tracks (customLookAt persists once set). + // NPCs. Re-asserted with every pose write, so a moving gaze tracks (customLookAt persists once set). if (hasLookAt_) Pup::DriveKerfurLookAt(actor, curLookAt_); // Drive the VISIBLE body facing -- set the mirror's ACharacter::Mesh WORLD yaw to the // host's streamed (interpolated) value. MUST be AFTER SetActorRotation above (moving the actor @@ -195,6 +193,7 @@ void Npc::ApplyToEngine() { // state machine matches the host (the mirror runs no AI -> it can't pick its own). Class-gated // inside -> safe no-op on non-kerfur NPCs. if (hasKerfState_) ue_wrap::kerfur::DriveKerfurState(actor, kerfState_, kerfSpooky_); + return moved && turned; } } // namespace coop::element diff --git a/src/votv-coop/src/coop/dev/event_drill.cpp b/src/votv-coop/src/coop/dev/event_drill.cpp index c00df3ee4..f0bf332c8 100644 --- a/src/votv-coop/src/coop/dev/event_drill.cpp +++ b/src/votv-coop/src/coop/dev/event_drill.cpp @@ -91,13 +91,16 @@ void TickClient() { // count first passes 100, so the [perf] lines of that window are the eggs' cost. bool g_eggFired = false; bool g_eggDone = false; +size_t g_eggLast = 0; // the previous sample's count: the window opens once it stops rising +int g_eggEmpty = 0; // samples since the first with no NPC mirror at all std::chrono::steady_clock::time_point g_eggSeen{}, g_eggNextSay{}; void TickEggHost(coop::net::Session& s) { if (g_eggFired || !s.IsSlotWorldReady(kSlot)) return; g_eggFired = true; UE_LOGI("[EGG-DRILL] host: the client's world is up -- the dev fire of eggvasion"); - EF::HostFire(EF::FireKind::RunEvent, L"eggvasion", L"None"); + if (!EF::HostFire(EF::FireKind::RunEvent, L"eggvasion", L"None")) + UE_LOGW("[EGG-DRILL] host FAIL -- the dev fire of eggvasion was refused"); } void TickEggClient() { @@ -108,7 +111,16 @@ void TickEggClient() { std::vector npcs; const size_t n = coop::element::Registry::Get().SnapshotActorsByType(coop::element::ElementType::Npc, npcs); UE_LOGI("[EGG-DRILL] client npc mirrors=%zu", n); - if (n <= 100) return; + const size_t last = g_eggLast; + g_eggLast = n; + // The eggs are minted one per tenth of a second after the fire reaches the host: a client that + // still holds none twelve samples (a minute) into its own world has nothing to measure. + if (n == 0 && ++g_eggEmpty >= 12) { + g_eggDone = true; + UE_LOGW("[EGG-DRILL] client FAIL -- no NPC mirror a minute in; the eggs never reached this peer"); + return; + } + if (n <= 100 || n > last) return; // still arriving: the window opens once the count settles if (g_eggSeen == std::chrono::steady_clock::time_point{}) { g_eggSeen = now; return; } if (now - g_eggSeen < std::chrono::seconds(30)) return; g_eggDone = true; diff --git a/src/votv-coop/src/coop/world/event_fire_sync.cpp b/src/votv-coop/src/coop/world/event_fire_sync.cpp index 9f69ad0dd..ca02a26ab 100644 --- a/src/votv-coop/src/coop/world/event_fire_sync.cpp +++ b/src/votv-coop/src/coop/world/event_fire_sync.cpp @@ -286,9 +286,10 @@ bool NativeFire(FireKind kind, const std::wstring& eventName, const std::wstring // The resolve latched each verb's function once; a watched call through another function (a // reloaded eventer class) is not announced, so it is said once rather than lost silently. void SayUnlatched(const char* verb, void* fn) { - static bool s_said = false; - if (s_said) return; - s_said = true; + static bool s_saidRun = false, s_saidSpecial = false; + bool& said = (verb[3] == 'S') ? s_saidSpecial : s_saidRun; // "runSpecialEvent" / "runEvent" + if (said) return; + said = true; UE_LOGW("event_fire: a host %s ran through function %p, not the one resolved -- that fire is NOT " "broadcast; the eventer class was reloaded?", verb, fn); } @@ -704,7 +705,7 @@ void OnClientWorldReady() { const size_t before = g_pending.size(); DrainPending(); if (g_pending.empty()) - UE_LOGI("event_fire: world ready -- the %zu queued fire(s) replayed or skipped", before); + UE_LOGI("event_fire: world ready -- the %zu queued fire(s) processed", before); else UE_LOGI("event_fire: world ready with %zu of %zu queued fire(s) still pending -- " "the install pump retries them each second", g_pending.size(), before); @@ -735,13 +736,14 @@ void OnDisconnect() { bool DevProbeClientRefusal() { if (!GT::IsGameThread() || !IsClientSession() || !ResolvePass()) return false; void* eventer = EventerOf(ue_wrap::world_singleton::Gamemode()); - if (!eventer || !g_runEventFn) return false; + // Through a live gate only: with the watch dead the call would run solar on this client. + if (!eventer || !g_runEventFn || !WatchLive(FireKind::RunEvent)) return false; ue_wrap::ParamFrame f(g_runEventFn); if (!f.valid() || !f.Set(L"event", ue_wrap::fname_utils::StringToFName(L"solar")) || !f.Set(L"special", ue_wrap::fname_utils::StringToFName(L"None"))) return false; - // The client's own call, as the game's event menu makes it: no admission is held, so the gate - // must refuse it. + // A reflected call holding no admission, as any client-native caller holds none: the gate must + // refuse it. const uint64_t before = g_clientRefused; ue_wrap::Call(eventer, f); return g_clientRefused == before + 1; From e5737dc8f2e27c6689da62c467c5586057359b59 Mon Sep 17 00:00:00 2001 From: pelmentor Date: Sat, 10 Oct 2026 04:40:06 +0600 Subject: [PATCH 054/306] [coop] container_birth selftest: the cap, a departure, a gone container No run reaches the transfer's arithmetic: throws are human-rate, so the per-author cap has never fired, and a departure needs a peer to leave with a throw in flight. The selftest drives the real functions at session start: an author over its cap gives up its own oldest and nobody else's, a departing author's transfers end, a gone container's ends at the sweep, and a first publication is pending, then given up once with its birth mark. It touches no engine object, since it runs on the session thread, so a live transfer's deadline stays an arm of the thrown-container drill. Its warnings are quiet meanwhile. Co-Authored-By: Claude Opus 5.5 --- .../include/coop/props/container_birth.h | 6 ++ .../src/coop/props/container_birth.cpp | 84 +++++++++++++++++-- src/votv-coop/src/harness/session_runtime.cpp | 3 + 3 files changed, 88 insertions(+), 5 deletions(-) diff --git a/src/votv-coop/include/coop/props/container_birth.h b/src/votv-coop/include/coop/props/container_birth.h index 59a4aeda6..1e9aab1cb 100644 --- a/src/votv-coop/include/coop/props/container_birth.h +++ b/src/votv-coop/include/coop/props/container_birth.h @@ -61,4 +61,10 @@ void Forget(uint32_t eid); void Reset(); +// The transfer's arithmetic, un-gated at session start before any transfer exists: the per-author cap +// that evicts that author's own oldest and nobody else's, a departing author's transfers, a gone +// container's at the sweep, and a first publication pending, then given up once. No engine object is +// touched. Leaves the tables empty. +bool RunSelftest(); + } // namespace coop::props::container_birth diff --git a/src/votv-coop/src/coop/props/container_birth.cpp b/src/votv-coop/src/coop/props/container_birth.cpp index 1e256af6b..500e425dc 100644 --- a/src/votv-coop/src/coop/props/container_birth.cpp +++ b/src/votv-coop/src/coop/props/container_birth.cpp @@ -33,6 +33,11 @@ std::vector g_awaited; // host std::set g_hostBirths; // host: births with contents, published by the next sweep std::map g_owedFirst; // eid -> deadline of its first publication +// The selftest drives the real functions, so it would otherwise print an eviction, a departure and an +// expiry into the session log -- lines a reader is supposed to treat as defects. Quiet for its +// duration only, as container_park's selftest is. +bool g_quiet = false; + uint64_t NowMs() { return static_cast( std::chrono::duration_cast( @@ -50,8 +55,9 @@ void Push(std::vector& births, void* actor, uint8_t authorSlot) { ++mine; } if (mine >= kMaxPerAuthor) { - UE_LOGW("container_birth: slot %u has %zu container transfers in flight -- its oldest is given up", - static_cast(authorSlot), mine); + if (!g_quiet) + UE_LOGW("container_birth: slot %u has %zu container transfers in flight -- its oldest is " + "given up", static_cast(authorSlot), mine); births.erase(oldest); } Birth b; @@ -99,8 +105,9 @@ void OnPeerGone(uint8_t slot) { // A transfer is bound to the peer that threw: a later occupant of the slot does not complete it. for (size_t i = 0; i < g_awaited.size();) { if (g_awaited[i].authorSlot != slot) { ++i; continue; } - UE_LOGW("container_birth: slot %u left before the contents of a container it threw came", - static_cast(slot)); + if (!g_quiet) + UE_LOGW("container_birth: slot %u left before the contents of a container it threw came", + static_cast(slot)); g_awaited.erase(g_awaited.begin() + static_cast(i)); } } @@ -141,7 +148,7 @@ void Sweep(uint64_t nowMs, std::set& dirty) { const Birth& b = g_awaited[i]; const bool live = b.actor.Alive(); if (live && nowMs < b.deadlineMs) { ++i; continue; } - if (live) + if (live && !g_quiet) UE_LOGW("container_birth: the contents of a container slot %u threw never came -- the host's " "copy stays as it built it, and the thrower's own contents are refused from now on and " "lost to it", static_cast(b.authorSlot)); @@ -182,4 +189,71 @@ void Reset() { g_owedFirst.clear(); } +bool RunSelftest() { + Reset(); + g_quiet = true; + int pass = 0, total = 0; + auto check = [&](bool ok, const char* what) { + ++total; + if (ok) { ++pass; return; } + UE_LOGE("container_birth selftest FAIL: %s", what); + }; + auto held = [](uint8_t slot) { + size_t n = 0; + for (const Birth& b : g_awaited) n += (b.authorSlot == slot) ? 1 : 0; + return n; + }; + // Tagged by its deadline, so which transfer an eviction took is visible. No actor: the selftest + // runs on the session thread, where no engine object may be touched, and the cap never reads one. + // A live transfer's deadline and its completion are the thrown-container drill's arms. + auto push = [](uint8_t slot, uint64_t tag) { + Push(g_awaited, nullptr, slot); + g_awaited.back().deadlineMs = tag; + }; + + // An author over its cap gives up its OWN oldest, never another author's. + push(2, 1); + for (uint64_t i = 0; i < kMaxPerAuthor; ++i) push(1, 100 + i); + push(1, 999); + check(held(1) == kMaxPerAuthor, "an author over its cap holds exactly the cap"); + check(held(2) == 1, "another author's transfer survives the eviction"); + bool oldestGone = true, newestHeld = false; + for (const Birth& b : g_awaited) { + if (b.authorSlot == 1 && b.deadlineMs == 100) oldestGone = false; + if (b.authorSlot == 1 && b.deadlineMs == 999) newestHeld = true; + } + check(oldestGone && newestHeld, "the transfer given up is that author's oldest"); + + // A departing author's transfers end, and only its; a gone container's ends at the next sweep. + OnPeerGone(1); + check(held(1) == 0 && held(2) == 1, "a departing author's transfers end, and only its"); + std::set dirty; + Sweep(0, dirty); + check(g_awaited.empty() && dirty.empty(), "a transfer whose container is gone ends at the sweep"); + + // A first publication stays owed until its deadline and is then given up, once, with its birth + // mark; a sent one and a forgotten one are owed nothing. + g_owedFirst[7] = 5000; + g_birthEids.insert(7); + check(FirstOwed(7, 4999) == Owed::Pending && IsBirthSlice(7), "an owed first publication is pending"); + check(FirstOwed(7, 5000) == Owed::GivenUp, "it is given up at its deadline"); + check(FirstOwed(7, 5000) == Owed::None && !IsBirthSlice(7), "given up once, its birth mark with it"); + g_owedFirst[8] = 5000; + g_birthEids.insert(8); + FirstSent(8); + check(FirstOwed(8, 0) == Owed::None && !IsBirthSlice(8), "a sent first publication is owed no more"); + g_owedFirst[9] = 5000; + Forget(9); + check(FirstOwed(9, 0) == Owed::None, "a container that no longer resolves owes nothing"); + + Reset(); + g_quiet = false; + if (pass == total) { + UE_LOGI("container_birth selftest: ALL PASS (%d checks)", total); + return true; + } + UE_LOGE("container_birth selftest: %d/%d checks passed", pass, total); + return false; +} + } // namespace coop::props::container_birth diff --git a/src/votv-coop/src/harness/session_runtime.cpp b/src/votv-coop/src/harness/session_runtime.cpp index 38155aa10..841b223f2 100644 --- a/src/votv-coop/src/harness/session_runtime.cpp +++ b/src/votv-coop/src/harness/session_runtime.cpp @@ -43,6 +43,7 @@ #include "coop/player/remote_player.h" #include "coop/player/roster.h" #include "coop/player/stat_orders_wire.h" +#include "coop/props/container_birth.h" #include "coop/props/container_park.h" #include "coop/props/container_write_policy.h" #include "coop/props/prop_lifecycle.h" @@ -240,6 +241,8 @@ bool StartCoopSession(const coop::net::Config& netCfg, coop::net::Refusal* why) // And the pen beside it, whose cap has never fired in a run: every park a measured join // produced was host-authored, and those are deliberately not capped. coop::props::container_park::RunSelftest(); + // And the transfer of a thrown container, whose cap, departure and deadline no run reaches. + coop::props::container_birth::RunSelftest(); // And the server id rule: a nickname or a hand-typed name never names a folder outside // multivoid_servers, and a wrong rule writes somewhere else without crashing. coop::server_profile::RunSelftest(); From 762bec817278161aece18966ead586cecd3cca5e Mon Sep 17 00:00:00 2001 From: pelmentor Date: Sat, 10 Oct 2026 08:10:42 +0600 Subject: [PATCH 055/306] [dev] director: a partial route is walked and asked for in legs The engine's path query spends a bounded search and returns a partial route on a long walk: on a fresh New Game the players start about 790 m from the base, and the route there stopped on a 2048-cm tile boundary. Reachability refused every standpoint whose first route ended short, and the walker re-planned only through its stuck detector. RouteInLegs asks again from each partial route's end; it refuses a leg that finds no route or ends less than a metre nearer, and stops after 16 legs. ReachableStandpoints uses it, the walker re-paths at once at a partial route's end, and the drive drill's private ring test is deleted. Co-Authored-By: Claude Opus 5.5 --- .../include/coop/dev/director/standpoints.h | 14 ++++++++-- .../src/coop/dev/director/proc_walkgrab.cpp | 13 ++++++++- .../src/coop/dev/director/standpoints.cpp | 26 +++++++++++++++-- src/votv-coop/src/coop/dev/drive_drill.cpp | 6 +++- .../src/coop/dev/drive_drill_verbs.cpp | 28 +------------------ .../src/coop/dev/drive_drill_verbs.h | 9 ++---- 6 files changed, 54 insertions(+), 42 deletions(-) diff --git a/src/votv-coop/include/coop/dev/director/standpoints.h b/src/votv-coop/include/coop/dev/director/standpoints.h index a584f5b22..9f4e103d4 100644 --- a/src/votv-coop/include/coop/dev/director/standpoints.h +++ b/src/votv-coop/include/coop/dev/director/standpoints.h @@ -9,9 +9,17 @@ namespace coop::director { -// The `count` points of a ring of `ringCm` about `about`, at the height `player` stands at, whose NavMesh route from -// `player` ends within `reachCm` of them, flat, shortest route first: the director's own test for a reachable pile -// (PickReachablePile). Empty when `player` does not read or no route reaches one. +// Whether a NavMesh route from `from` reaches `to` (its end within `reachCm`, flat), asked in legs: the engine's path +// query spends a bounded search and returns a PARTIAL route on a long walk -- measured on a fresh New Game, whose +// players start about 790 m from the base and whose route to it stopped on a 2048-cm tile boundary -- so each leg asks +// again from the last one's end, as the walker does when it reaches it (GotoProcess). False when a leg finds no route +// or ends no closer to `to` than it began, or after `maxLegs`. `outLength`, when given, is the route's flat length. +bool RouteInLegs(void* player, const ue_wrap::FVector& from, const ue_wrap::FVector& to, float reachCm, int maxLegs, + float* outLength); + +// The `count` points of a ring of `ringCm` about `about`, at the height `player` stands at, that a route from `player` +// reaches (RouteInLegs), shortest route first: the director's own test for a reachable pile (PickReachablePile). +// Empty when `player` does not read or no route reaches one. std::vector ReachableStandpoints(void* player, const ue_wrap::FVector& about, float ringCm, int count, float reachCm); diff --git a/src/votv-coop/src/coop/dev/director/proc_walkgrab.cpp b/src/votv-coop/src/coop/dev/director/proc_walkgrab.cpp index 22dadb425..3aaa9b288 100644 --- a/src/votv-coop/src/coop/dev/director/proc_walkgrab.cpp +++ b/src/votv-coop/src/coop/dev/director/proc_walkgrab.cpp @@ -153,7 +153,7 @@ class GotoProcess : public IProcess { return onLevel_ ? !Arrived(ctx, goal_) : HorizDist(ctx.pos, goal_.targetPos) > goal_.reachCm; } ProcStatus OnTick(const PlayerContext& ctx) override { - if (!pathed_) { // compute the route once (after the hand is clear, so from the real start) + if (!pathed_) { // compute the route (after the hand is clear, so from the real start), again per leg pathed_ = true; std::vector path; const bool ok = !goal_.straight && E::FindNavPath(ctx.player, ctx.pos, goal_.targetPos, path); @@ -161,6 +161,9 @@ class GotoProcess : public IProcess { else waypoints_.push_back(goal_.targetPos); // the straight line, asked for or the fallback goal_.route.assign(1, ok && !path.empty() ? path.front() : ctx.pos); goal_.route.insert(goal_.route.end(), waypoints_.begin(), waypoints_.end()); + // A route that stops short of the goal is PARTIAL (the path query's bounded search on a long walk): + // its end is a leg boundary, re-planned from there on arrival rather than by the stuck detector. + partial_ = ok && HorizDist(waypoints_.back(), goal_.targetPos) > goal_.reachCm; UE_LOGI("director/Goto: route %zu waypoints (%s), dist=%.0fcm", waypoints_.size(), ok ? "NavMesh route" : goal_.straight ? "straight line, as asked" : "straight-line fallback", HorizDist(ctx.pos, goal_.targetPos)); @@ -168,6 +171,12 @@ class GotoProcess : public IProcess { const float toPile = HorizDist(ctx.pos, goal_.targetPos); while (wp_ + 1 < waypoints_.size() && HorizDist(ctx.pos, waypoints_[wp_]) < kAdvanceCm) ++wp_; const float toWp = HorizDist(ctx.pos, waypoints_[wp_]); + if (partial_ && wp_ + 1 == waypoints_.size() && toWp < kAdvanceCm) { + ++legs_; + UE_LOGI("director/Goto: leg %d reached (toPile=%.0f) -- the route was partial, re-path from here", legs_, toPile); + pathed_ = false; waypoints_.clear(); wp_ = 0; lastWp_ = 0; bestWp_ = 1e9f; bestPile_ = 1e9f; partial_ = false; + return ProcStatus::Working; + } // Progress = advanced a waypoint OR closer to the current waypoint OR closer to the pile. bool progressed = false; if (wp_ > lastWp_) { progressed = true; lastWp_ = wp_; bestWp_ = 1e9f; } @@ -247,6 +256,8 @@ class GotoProcess : public IProcess { DirectorGoal& goal_; const bool onLevel_; bool pathed_ = false; + bool partial_ = false; // the current route stops short of the goal: its end is a leg boundary + int legs_ = 0; std::vector waypoints_; size_t wp_ = 0, lastWp_ = 0; float bestWp_ = 1e9f, bestPile_ = 1e9f; diff --git a/src/votv-coop/src/coop/dev/director/standpoints.cpp b/src/votv-coop/src/coop/dev/director/standpoints.cpp index fac94d270..5962b2261 100644 --- a/src/votv-coop/src/coop/dev/director/standpoints.cpp +++ b/src/votv-coop/src/coop/dev/director/standpoints.cpp @@ -16,8 +16,30 @@ namespace E = ue_wrap::engine; float Flat(const ue_wrap::FVector& a, const ue_wrap::FVector& b) { return std::hypot(a.X - b.X, a.Y - b.Y); } +constexpr int kMaxLegs = 16; // a walk across the map is a handful of partial routes +constexpr float kMinLegProgressCm = 100.f; // a leg that ends no nearer than this has found the edge of the reachable + } // namespace +bool RouteInLegs(void* player, const ue_wrap::FVector& from, const ue_wrap::FVector& to, float reachCm, int maxLegs, + float* outLength) { + ue_wrap::FVector cur = from; + float len = 0.f; + for (int leg = 0; leg < maxLegs; ++leg) { + std::vector route; + if (!E::FindNavPath(player, cur, to, route) || route.empty()) return false; + for (size_t k = 1; k < route.size(); ++k) len += Flat(route[k - 1], route[k]); + const ue_wrap::FVector end = route.back(); + if (Flat(end, to) <= reachCm) { + if (outLength) *outLength = len; + return true; + } + if (Flat(end, to) > Flat(cur, to) - kMinLegProgressCm) return false; // no nearer: unreachable from here + cur = end; + } + return false; +} + std::vector ReachableStandpoints(void* player, const ue_wrap::FVector& about, float ringCm, int count, float reachCm) { ue_wrap::FVector from; @@ -28,10 +50,8 @@ std::vector ReachableStandpoints(void* player, const ue_wrap:: ue_wrap::FVector p = from; // at the height the player stands at p.X = about.X + ringCm * std::cos(a); p.Y = about.Y + ringCm * std::sin(a); - std::vector route; - if (!E::FindNavPath(player, from, p, route) || route.empty() || Flat(route.back(), p) > reachCm) continue; float len = 0.f; - for (size_t k = 1; k < route.size(); ++k) len += Flat(route[k - 1], route[k]); + if (!RouteInLegs(player, from, p, reachCm, kMaxLegs, &len)) continue; found.emplace_back(len, p); } std::stable_sort(found.begin(), found.end(), [](const auto& x, const auto& y) { return x.first < y.first; }); diff --git a/src/votv-coop/src/coop/dev/drive_drill.cpp b/src/votv-coop/src/coop/dev/drive_drill.cpp index 80d8b8d5f..d9c5b9090 100644 --- a/src/votv-coop/src/coop/dev/drive_drill.cpp +++ b/src/votv-coop/src/coop/dev/drive_drill.cpp @@ -6,6 +6,7 @@ #include "coop/config/config.h" #include "coop/dev/director/director.h" +#include "coop/dev/director/standpoints.h" #include "coop/element/registry.h" #include "coop/interactables/drive_payload_sync.h" #include "coop/interactables/drive_sync.h" @@ -267,7 +268,10 @@ void ClientTick() { Abandon("this client's world has no eraser, or no player, to walk with"); return; } - g_ring = DV::ReachableRing(player, eraser, kStandpoints, kStandRingCm, kStandReachCm); + ue_wrap::FVector at{}; + g_ring = E::TryGetActorLocation(eraser, at) + ? coop::director::ReachableStandpoints(player, at, kStandRingCm, kStandpoints, kStandReachCm) + : std::vector(); UE_LOGI("[DRIVE-DRILL] client: a route reaches %zu of the %d standpoints about the eraser", g_ring.size(), kStandpoints); if (g_ring.empty()) { diff --git a/src/votv-coop/src/coop/dev/drive_drill_verbs.cpp b/src/votv-coop/src/coop/dev/drive_drill_verbs.cpp index f1e5e080e..45d1339bc 100644 --- a/src/votv-coop/src/coop/dev/drive_drill_verbs.cpp +++ b/src/votv-coop/src/coop/dev/drive_drill_verbs.cpp @@ -6,12 +6,9 @@ #include "ue_wrap/core/call.h" #include "ue_wrap/core/reflection.h" #include "ue_wrap/engine/engine.h" -#include "ue_wrap/engine/engine_nav.h" // FindNavPath #include "ue_wrap/world/world_singleton.h" -#include -#include -#include +#include namespace coop::dev::drive_drill_verbs { namespace { @@ -19,35 +16,12 @@ namespace { namespace E = ue_wrap::engine; namespace R = ue_wrap::reflection; -float Flat(const ue_wrap::FVector& a, const ue_wrap::FVector& b) { return std::hypot(a.X - b.X, a.Y - b.Y); } - void* PlayerFn(void* player, const wchar_t* name) { return player ? R::FindDispatchFunctionCached(R::ClassOf(player), name) : nullptr; } } // namespace -std::vector ReachableRing(void* player, void* target, int points, float ringCm, float reachCm) { - ue_wrap::FVector from{}, at{}; - if (!E::TryGetActorLocation(player, from) || !E::TryGetActorLocation(target, at)) return {}; - std::vector> found; - for (int i = 0; i < points; ++i) { - const float a = 6.2831853f * static_cast(i) / static_cast(points); - ue_wrap::FVector p = from; // at the height the player stands at - p.X = at.X + ringCm * std::cos(a); - p.Y = at.Y + ringCm * std::sin(a); - std::vector route; - if (!E::FindNavPath(player, from, p, route) || route.empty() || Flat(route.back(), p) > reachCm) continue; - float len = 0.f; - for (size_t k = 1; k < route.size(); ++k) len += Flat(route[k - 1], route[k]); - found.emplace_back(len, p); - } - std::stable_sort(found.begin(), found.end(), [](const auto& x, const auto& y) { return x.first < y.first; }); - std::vector out; - for (const auto& f : found) out.push_back(f.second); - return out; -} - bool Pocket(void* player, void* prop) { void* fn = PlayerFn(player, L"putObjectInventory2"); ue_wrap::ParamFrame f(fn); diff --git a/src/votv-coop/src/coop/dev/drive_drill_verbs.h b/src/votv-coop/src/coop/dev/drive_drill_verbs.h index 776e9bd3c..01407c7a6 100644 --- a/src/votv-coop/src/coop/dev/drive_drill_verbs.h +++ b/src/votv-coop/src/coop/dev/drive_drill_verbs.h @@ -1,20 +1,15 @@ // coop/dev/drive_drill_verbs.h -- the player's own verbs the drive drill drives, each the game's own, as a player's -// input reaches it: the pocket, the take-out from the inventory, the hand's pickup and throw, and a walk target a -// NavMesh route reaches. A private header of coop/dev/drive_drill.cpp (src tree, not include/). +// input reaches it: the pocket, the take-out from the inventory, the hand's pickup and throw. A private header of +// coop/dev/drive_drill.cpp (src tree, not include/). #pragma once #include "ue_wrap/core/types.h" #include -#include namespace coop::dev::drive_drill_verbs { -// The ring's points about `target` that a NavMesh route from `player` reaches (the route's end within `reachCm` of -// the point), shortest route first: the director's own test for a reachable pile. -std::vector ReachableRing(void* player, void* target, int points, float ringCm, float reachCm); - // mainPlayer.putObjectInventory2: `prop` into the player's inventory, the world actor gone. True when it was taken. bool Pocket(void* player, void* prop); From 2f6cebc7a62c11ec58b16e593e33c136ba6957c2 Mon Sep 17 00:00:00 2001 From: pelmentor Date: Sat, 10 Oct 2026 08:25:08 +0600 Subject: [PATCH 056/306] [dev] director: a persistent stuck names what blocks the walk The walker re-paths every fourth stuck episode, and on a fresh New Game it stood 2 m short of a waypoint for minutes while each fresh route led back to the same spot: the NavMesh routes through something the capsule cannot pass. The stuck line now traces toward the waypoint at knee and chest height and down to the floor, naming the hit actor, its component and the surface normal, and says once the walker's floor and step limits beside the ones the NavMesh was built with. ue_wrap::trace gains LineHitStatDyn, read from its FHitResult by reflected offsets. Co-Authored-By: Claude Opus 5.5 --- src/votv-coop/include/ue_wrap/core/trace.h | 17 +++- .../include/ue_wrap/engine/engine_nav.h | 18 ++++ .../src/coop/dev/director/proc_walkgrab.cpp | 50 +++++++++++ src/votv-coop/src/ue_wrap/core/trace.cpp | 85 +++++++++++++++---- .../src/ue_wrap/engine/engine_nav.cpp | 34 ++++++++ 5 files changed, 187 insertions(+), 17 deletions(-) diff --git a/src/votv-coop/include/ue_wrap/core/trace.h b/src/votv-coop/include/ue_wrap/core/trace.h index eeffddaca..71692c918 100644 --- a/src/votv-coop/include/ue_wrap/core/trace.h +++ b/src/votv-coop/include/ue_wrap/core/trace.h @@ -3,8 +3,8 @@ // layer: wraps the KSL CDO/UFunction resolve + the ParamFrame plumbing of a world // line trace. NO network logic, NO gameplay state. // -// One owner of the trace primitive, with two consumers: wisp.cpp's canReach parity -// trace and nameplate occlusion. +// One owner of the trace primitive, with three consumers: wisp.cpp's canReach parity +// trace, nameplate occlusion, and the director's stuck line, which names what it hit. #pragma once @@ -24,4 +24,17 @@ namespace ue_wrap::trace { // Game thread only (dispatches a UFunction). int LineBlockedStatDyn(void* worldCtx, const FVector& start, const FVector& end); +// What the same trace hits first: the hit's actor and component (live, or null), and its +// impact point and normal, read from the call's FHitResult by the struct's reflected member +// offsets. False when unresolvable (as -1 above); `out->blocked` false on a clear line. +// Game thread only. +struct Hit { + bool blocked = false; + void* actor = nullptr; + void* component = nullptr; + FVector point{}; + FVector normal{}; +}; +bool LineHitStatDyn(void* worldCtx, const FVector& start, const FVector& end, Hit* out); + } // namespace ue_wrap::trace diff --git a/src/votv-coop/include/ue_wrap/engine/engine_nav.h b/src/votv-coop/include/ue_wrap/engine/engine_nav.h index 3d7a4843e..dfbc934ab 100644 --- a/src/votv-coop/include/ue_wrap/engine/engine_nav.h +++ b/src/votv-coop/include/ue_wrap/engine/engine_nav.h @@ -20,4 +20,22 @@ bool FindNavPath(void* worldContext, const FVector& start, const FVector& end, // consumes each tick, so re-issue it every frame. void AddMovementInput(void* pawn, const FVector& worldDir, float scale, bool force); +// The limits a walking character moves under, from its CharacterMovement: the lowest floor normal +// Z it stands on (WalkableFloorZ) and the highest step it climbs. False when a read fails. +struct WalkLimits { + float floorZ = 0.f; + float stepCm = 0.f; +}; +bool ReadWalkLimits(void* character, WalkLimits* out); + +// The limits the level's NavMesh was built with (its RecastNavMesh): the steepest slope in degrees +// and the highest step a route may take, and the radius a route keeps from walls. False when none +// is loaded. +struct NavLimits { + float maxSlopeDeg = 0.f; + float stepCm = 0.f; + float radiusCm = 0.f; +}; +bool ReadNavLimits(NavLimits* out); + } // namespace ue_wrap::engine diff --git a/src/votv-coop/src/coop/dev/director/proc_walkgrab.cpp b/src/votv-coop/src/coop/dev/director/proc_walkgrab.cpp index 3aaa9b288..f3bd6b9cd 100644 --- a/src/votv-coop/src/coop/dev/director/proc_walkgrab.cpp +++ b/src/votv-coop/src/coop/dev/director/proc_walkgrab.cpp @@ -21,12 +21,15 @@ #include "ue_wrap/core/log.h" #include "ue_wrap/core/reflection.h" #include "ue_wrap/core/sdk_profile.h" +#include "ue_wrap/core/trace.h" #include "ue_wrap/devices/door.h" #include "ue_wrap/engine/engine.h" +#include #include #include #include +#include #include namespace coop::director { @@ -141,6 +144,52 @@ class ClearHandProcess : public IProcess { bool measured_ = false; }; +// What stands between a stuck walker and its waypoint, named: a trace at knee and at chest height +// toward the waypoint (2 m at most) and one down for the floor's slope. The NavMesh routes through +// what the capsule cannot pass, and this line says what that is. On a persistent stuck only. +void LogBlocker(const PlayerContext& ctx, const ue_wrap::FVector& wp) { + ue_wrap::FVector d{wp.X - ctx.pos.X, wp.Y - ctx.pos.Y, 0.f}; + const float h = std::sqrt(d.X * d.X + d.Y * d.Y); + if (h < 1.f) return; + d.X /= h; d.Y /= h; + const float reach = (std::min)(h + 50.f, 200.f); + const auto describe = [&](const char* what, const ue_wrap::FVector& a, const ue_wrap::FVector& b) { + ue_wrap::trace::Hit hit; + if (!ue_wrap::trace::LineHitStatDyn(ctx.player, a, b, &hit)) { + UE_LOGW("director/Goto: %s trace unresolved", what); + } else if (!hit.blocked) { + UE_LOGW("director/Goto: %s trace clear for %.0f cm", what, reach); + } else { + const std::wstring actor = hit.actor ? R::ToString(R::NameOf(hit.actor)) : L"?"; + const std::wstring actorCls = hit.actor ? R::ClassNameOf(hit.actor) : L"?"; + const std::wstring comp = hit.component ? R::ToString(R::NameOf(hit.component)) : L"?"; + const std::wstring compCls = hit.component ? R::ClassNameOf(hit.component) : L"?"; + UE_LOGW("director/Goto: %s trace hits '%ls' (%ls) component '%ls' (%ls) at (%.0f,%.0f,%.0f), " + "%.0f cm ahead, normal (%.2f,%.2f,%.2f)", what, actor.c_str(), actorCls.c_str(), comp.c_str(), + compCls.c_str(), hit.point.X, hit.point.Y, hit.point.Z, + std::hypot(hit.point.X - a.X, hit.point.Y - a.Y), hit.normal.X, hit.normal.Y, hit.normal.Z); + } + }; + UE_LOGW("director/Goto: blocked at (%.0f,%.0f,%.0f) toward the waypoint (%.0f,%.0f,%.0f), %.0f cm away", + ctx.pos.X, ctx.pos.Y, ctx.pos.Z, wp.X, wp.Y, wp.Z, h); + static bool s_limitsSaid = false; // both sets of limits, once: what a route may take against what the body can + if (!s_limitsSaid) { + s_limitsSaid = true; + E::WalkLimits w; + E::NavLimits n; + const bool wOk = E::ReadWalkLimits(ctx.player, &w), nOk = E::ReadNavLimits(&n); + UE_LOGW("director/Goto: the walker: floor normal z >= %.3f (%.1f deg), steps %.0f cm%s; the NavMesh's build: " + "%.1f deg, steps %.0f cm, radius %.0f cm%s", w.floorZ, + std::acos((std::max)(-1.f, (std::min)(1.f, w.floorZ))) * (180.f / 3.14159265358979323846f), w.stepCm, + wOk ? "" : " (unread)", n.maxSlopeDeg, n.stepCm, n.radiusCm, nOk ? "" : " (unread)"); + } + for (const float dz : {-50.f, 30.f}) { + const ue_wrap::FVector a{ctx.pos.X, ctx.pos.Y, ctx.pos.Z + dz}; + describe(dz < 0.f ? "knee" : "chest", a, {a.X + d.X * reach, a.Y + d.Y * reach, a.Z}); + } + describe("floor", ctx.pos, {ctx.pos.X, ctx.pos.Y, ctx.pos.Z - 300.f}); +} + // ---- GotoProcess ----------------------------------------------------------------------- class GotoProcess : public IProcess { public: @@ -193,6 +242,7 @@ class GotoProcess : public IProcess { // DEADLINE (>= 30 s of grinding) are the terminators, not an early stuck-count. ++stuckEpisodes_; if (stuckEpisodes_ % 4 == 0) { // periodic fresh route from where we actually are + LogBlocker(ctx, waypoints_[wp_]); pathed_ = false; waypoints_.clear(); wp_ = 0; lastWp_ = 0; bestWp_ = 1e9f; bestPile_ = 1e9f; UE_LOGW("director/Goto: persistent stuck (toPile=%.0f) -- RE-PATH (episode %d, never give up)", toPile, stuckEpisodes_); return ProcStatus::Working; diff --git a/src/votv-coop/src/ue_wrap/core/trace.cpp b/src/votv-coop/src/ue_wrap/core/trace.cpp index 221e15929..299bb365a 100644 --- a/src/votv-coop/src/ue_wrap/core/trace.cpp +++ b/src/votv-coop/src/ue_wrap/core/trace.cpp @@ -4,8 +4,11 @@ #include "ue_wrap/core/call.h" #include "ue_wrap/core/reflection.h" +#include "ue_wrap/core/reflection_props.h" #include +#include +#include namespace ue_wrap::trace { namespace { @@ -25,20 +28,9 @@ uint8_t g_traceObjTypes[2] = {0, 1}; struct TArrayControl { void* data; int32_t num; int32_t max; }; #pragma pack(pop) -} // namespace - -int LineBlockedStatDyn(void* worldCtx, const FVector& start, const FVector& end) { - if (!worldCtx) return -1; - if (!g_traceFn || !g_kslCdo) { - if (!g_kslCdo) g_kslCdo = R::FindClassDefaultObject(L"KismetSystemLibrary"); - if (!g_traceFn) { - if (void* kc = R::FindClass(L"KismetSystemLibrary")) - g_traceFn = R::FindFunction(kc, L"LineTraceSingleForObjects"); - } - if (!g_traceFn || !g_kslCdo) return -1; - } - ParamFrame f(g_traceFn); - if (!f.valid()) return -1; +// The trace, run into `f`. False when unresolvable. +bool Run(ParamFrame& f, void* worldCtx, const FVector& start, const FVector& end) { + if (!f.valid()) return false; f.Set(L"WorldContextObject", worldCtx); f.Set(L"Start", start); f.Set(L"End", end); @@ -49,8 +41,71 @@ int LineBlockedStatDyn(void* worldCtx, const FVector& start, const FVector& end) // DrawDebugType 0 = None; OutHit is an in-frame zeroed FHitResult (POD members // only -- FName/floats/weak ptrs -- so no destructor concerns on our frame). f.Set(L"bIgnoreSelf", true); - if (!ue_wrap::Call(g_kslCdo, f)) return -1; + return ue_wrap::Call(g_kslCdo, f); +} + +bool Resolved() { + if (!g_kslCdo) g_kslCdo = R::FindClassDefaultObject(L"KismetSystemLibrary"); + if (!g_traceFn) { + if (void* kc = R::FindClass(L"KismetSystemLibrary")) + g_traceFn = R::FindFunction(kc, L"LineTraceSingleForObjects"); + } + return g_traceFn && g_kslCdo; +} + +// FHitResult's members, by the engine struct's own reflection, resolved once. +struct HitLayout { + bool tried = false, ok = false; + int32_t size = 0, point = -1, normal = -1, actor = -1, component = -1; +}; +const HitLayout& Layout() { + static HitLayout l; + if (l.tried) return l; + l.tried = true; + void* hr = R::FindObject(L"HitResult", L"ScriptStruct"); + l.size = R::FindParamSize(g_traceFn, L"OutHit"); + if (!hr || l.size <= 0) return l; + l.point = R::FindPropertyOffset(hr, L"ImpactPoint"); + l.normal = R::FindPropertyOffset(hr, L"ImpactNormal"); + l.actor = R::FindPropertyOffset(hr, L"Actor"); + l.component = R::FindPropertyOffset(hr, L"Component"); + const auto fits = [&](int32_t off, int32_t n) { return off >= 0 && off + n <= l.size; }; + l.ok = fits(l.point, 12) && fits(l.normal, 12) && fits(l.actor, 8) && fits(l.component, 8); + return l; +} + +void* Weak(const unsigned char* at) { + int32_t idx = 0, serial = 0; + std::memcpy(&idx, at, 4); + std::memcpy(&serial, at + 4, 4); + return idx < 0 ? nullptr : R::ResolveWeakObject(idx, serial); +} + +} // namespace + +int LineBlockedStatDyn(void* worldCtx, const FVector& start, const FVector& end) { + if (!worldCtx || !Resolved()) return -1; + ParamFrame f(g_traceFn); + if (!Run(f, worldCtx, start, end)) return -1; return f.Get(L"ReturnValue") ? 1 : 0; } +bool LineHitStatDyn(void* worldCtx, const FVector& start, const FVector& end, Hit* out) { + if (!out || !worldCtx || !Resolved()) return false; + const HitLayout& l = Layout(); + if (!l.ok) return false; + ParamFrame f(g_traceFn); + if (!Run(f, worldCtx, start, end)) return false; + *out = Hit{}; + out->blocked = f.Get(L"ReturnValue"); + if (!out->blocked) return true; + std::vector hit(static_cast(l.size)); + if (!f.GetRaw(L"OutHit", hit.data(), l.size)) return false; + std::memcpy(&out->point, hit.data() + l.point, 12); + std::memcpy(&out->normal, hit.data() + l.normal, 12); + out->actor = Weak(hit.data() + l.actor); + out->component = Weak(hit.data() + l.component); + return true; +} + } // namespace ue_wrap::trace diff --git a/src/votv-coop/src/ue_wrap/engine/engine_nav.cpp b/src/votv-coop/src/ue_wrap/engine/engine_nav.cpp index 16ca78d8a..7ecdbe629 100644 --- a/src/votv-coop/src/ue_wrap/engine/engine_nav.cpp +++ b/src/votv-coop/src/ue_wrap/engine/engine_nav.cpp @@ -15,10 +15,13 @@ #include "ue_wrap/core/call.h" #include "ue_wrap/core/log.h" #include "ue_wrap/core/reflection.h" +#include "ue_wrap/core/reflection_props.h" #include "ue_wrap/core/types.h" +#include "ue_wrap/world/world_singleton.h" #include #include +#include namespace ue_wrap::engine { namespace { @@ -89,4 +92,35 @@ void AddMovementInput(void* pawn, const FVector& worldDir, float scale, bool for Call(pawn, pf); } +namespace { + +// A float member read by its reflected name on the object's own class. False when it is not there. +// The walk is linear; both readers below are one-shot diagnostics. +bool ReadFloatMember(void* obj, const wchar_t* name, float* out) { + if (!obj) return false; + const int32_t off = R::FindPropertyOffset(R::ClassOf(obj), name); + if (off < 0) return false; + std::memcpy(out, static_cast(obj) + off, sizeof(float)); + return true; +} + +} // namespace + +bool ReadWalkLimits(void* character, WalkLimits* out) { + if (!character || !out) return false; + const int32_t off = R::FindPropertyOffset(R::ClassOf(character), L"CharacterMovement"); + if (off < 0) return false; + void* cmc = nullptr; + std::memcpy(&cmc, static_cast(character) + off, sizeof(cmc)); + return cmc && R::IsLive(cmc) && ReadFloatMember(cmc, L"WalkableFloorZ", &out->floorZ) && + ReadFloatMember(cmc, L"MaxStepHeight", &out->stepCm); +} + +bool ReadNavLimits(NavLimits* out) { + void* nav = out ? world_singleton::Find(L"RecastNavMesh") : nullptr; + return nav && ReadFloatMember(nav, L"AgentMaxSlope", &out->maxSlopeDeg) && + ReadFloatMember(nav, L"AgentMaxStepHeight", &out->stepCm) && + ReadFloatMember(nav, L"AgentRadius", &out->radiusCm); +} + } // namespace ue_wrap::engine From a49ffd56e212fa2e8ada969d6daef20c908fa74a Mon Sep 17 00:00:00 2001 From: pelmentor Date: Sat, 10 Oct 2026 08:50:59 +0600 Subject: [PATCH 057/306] [dev] director: a slope too steep for the body is walked round The level's NavMesh is built for a 45.5-degree slope and the player's floor limit is 40.1 (both read on a fresh New Game), so a route may climb a flank the body slides off: the walker stood 2 m short of a waypoint on a 44-degree slope while each fresh route led back to it. A stuck walker whose way ahead is such a slope now remembers the spot and searches rings round it for a via point whose route stands on floor the body can hold, and whose route on to the goal passes no remembered spot; a fresh route that passes one goes round it at once. Walls stay the stuck handler's. Co-Authored-By: Claude Opus 5.5 --- .../include/coop/dev/director/detour.h | 62 ++++++ src/votv-coop/sources.cmake | 1 + .../src/coop/dev/director/detour.cpp | 194 ++++++++++++++++++ .../src/coop/dev/director/proc_walkgrab.cpp | 39 ++++ 4 files changed, 296 insertions(+) create mode 100644 src/votv-coop/include/coop/dev/director/detour.h create mode 100644 src/votv-coop/src/coop/dev/director/detour.cpp diff --git a/src/votv-coop/include/coop/dev/director/detour.h b/src/votv-coop/include/coop/dev/director/detour.h new file mode 100644 index 000000000..379d8e314 --- /dev/null +++ b/src/votv-coop/include/coop/dev/director/detour.h @@ -0,0 +1,62 @@ +// coop/dev/director/detour.h -- the way round a surface the walker's body cannot climb. +// +// The level's NavMesh is built for a steeper slope than the player walks (measured on a fresh New +// Game: the NavMesh 45.5 degrees, the player's floor limit 40.1), so a route may climb a flank the +// body slides off -- the walker stood 2 m short of a waypoint on a 44-degree slope for minutes, each +// fresh route leading back to the same spot. The NavMesh proposes and the body's own floor limit +// disposes: a walker stopped by a surface too steep to stand on remembers that spot and asks for a +// via point, one a route reaches over floor the body stands on and from which the route on to the +// goal passes no remembered spot. Dev director only; game thread. +#pragma once + +#include "ue_wrap/core/types.h" + +#include +#include + +namespace coop::director::detour { + +// Whether the floor under `at`, traced down from above it, is one the body stands on: its normal Z +// at least `floorZ`. True where nothing is hit, or the trace does not resolve, since a NavMesh point +// has floor and a doubt is no reason to refuse a route. +bool FloorStandable(void* player, const ue_wrap::FVector& at, float floorZ); + +// Whether the way from `pos` toward `toward` is a slope too steep for the body: a knee-height trace, +// 2 m at most, hits a surface whose normal Z is under `floorZ` and flatter than a wall (a door, a box +// or a fence is the stuck handler's own). `spot` is where it hit. +bool SteepAhead(void* player, const ue_wrap::FVector& pos, const ue_wrap::FVector& toward, float floorZ, + ue_wrap::FVector* spot); + +class Search { +public: + // Remembers the spot `steep` and starts a search round it, from `from` toward `goal`. + void Begin(const ue_wrap::FVector& from, const ue_wrap::FVector& goal, const ue_wrap::FVector& steep, + float floorZ); + // Starts a search round a spot already remembered, the one `route` passes (PassedSpot). + void BeginRound(const ue_wrap::FVector& from, const ue_wrap::FVector& goal, size_t spot, float floorZ); + + enum class Step { Working, Found, Exhausted }; + // A few candidates per call, each a route query and its floor samples. On Found, `route` is the + // way to the via point, its first point the walker's start. Exhausted: no candidate held. + Step Advance(void* player, std::vector* route); + + // The remembered spot a route passes within clearance of, past its first stretch, or -1. + int PassedSpot(const std::vector& route) const; + + bool Active() const { return active_; } + size_t Spots() const { return spots_.size(); } + +private: + bool Holds(void* player, const ue_wrap::FVector& via, std::vector* toVia, + float* score) const; + + std::vector spots_; + ue_wrap::FVector from_{}, goal_{}, round_{}; + float floorZ_ = 0.f; + bool active_ = false; + int next_ = 0; // the next candidate: ring * kDirections + direction + float bestScore_ = 0.f; + std::vector best_; // the best held route of the ring under evaluation +}; + +} // namespace coop::director::detour diff --git a/src/votv-coop/sources.cmake b/src/votv-coop/sources.cmake index 929220903..43bd383c2 100644 --- a/src/votv-coop/sources.cmake +++ b/src/votv-coop/sources.cmake @@ -611,6 +611,7 @@ set(VOTVCOOP_SOURCES src/coop/dev/dev_gate.cpp src/coop/dev/director/player_context.cpp src/coop/dev/director/control_manager.cpp + src/coop/dev/director/detour.cpp src/coop/dev/director/proc_walkgrab.cpp src/coop/dev/director/director_run.cpp src/coop/dev/director/container_take_probe.cpp diff --git a/src/votv-coop/src/coop/dev/director/detour.cpp b/src/votv-coop/src/coop/dev/director/detour.cpp new file mode 100644 index 000000000..47150c725 --- /dev/null +++ b/src/votv-coop/src/coop/dev/director/detour.cpp @@ -0,0 +1,194 @@ +// coop/dev/director/detour.cpp -- see detour.h. + +#include "coop/dev/director/detour.h" + +#include "ue_wrap/core/log.h" +#include "ue_wrap/core/trace.h" +#include "ue_wrap/engine/engine_nav.h" + +#include +#include +#include + +namespace coop::director::detour { +namespace { + +namespace E = ue_wrap::engine; + +constexpr float kRingsCm[] = {600.f, 1200.f, 2400.f, 4800.f}; // nearest first: the shortest way round +constexpr int kRings = sizeof(kRingsCm) / sizeof(kRingsCm[0]); +constexpr int kDirections = 12; +constexpr int kPerAdvance = 2; // candidates per call, a route query and its samples each +constexpr float kSampleCm = 100.f; // floor samples along a route, about a stride apart +constexpr float kSpotClearCm = 300.f; // a route keeps this far from a remembered spot +constexpr float kSpotLevelCm = 300.f; // ...on its level: a spot on a slope below is not one above +constexpr float kOnwardCheckCm = 1500.f; // the onward route's floor is sampled this far past the via point +constexpr float kViaReachCm = 150.f; // a route to the via point ends this near it +constexpr float kProbeUpCm = 150.f; // FloorStandable's trace starts this far above the point... +constexpr float kProbeDownCm = 300.f; // ...and ends this far below it +constexpr float kDropCm = 2000.f; // a candidate is put on the floor within this above or below it +constexpr float kViaLiftCm = 50.f; // ...and this far above that floor, inside the path query's reach +constexpr float kTraceAheadCm = 200.f; // SteepAhead looks this far toward the waypoint at most +constexpr float kKneeCm = -50.f; // ...at knee height, under the walker's centre +constexpr float kWallNormalZ = 0.3f; // a hit flatter than a wall: a slope, not a door, box or fence +constexpr size_t kMaxSpots = 32; + +float Flat(const ue_wrap::FVector& a, const ue_wrap::FVector& b) { return std::hypot(a.X - b.X, a.Y - b.Y); } + +float Length(const std::vector& r) { + float len = 0.f; + for (size_t k = 1; k < r.size(); ++k) len += Flat(r[k - 1], r[k]); + return len; +} + +// Calls `fn(point, along)` about every kSampleCm along the route, both ends included, `along` the +// flat distance from its start; stops at the first false and returns false. +template +bool Walk(const std::vector& r, Fn fn) { + if (r.size() == 1) return fn(r[0], 0.f); + float along = 0.f; + for (size_t k = 1; k < r.size(); ++k) { + const ue_wrap::FVector a = r[k - 1], b = r[k]; + const float seg = Flat(a, b); + const int n = (std::max)(1, static_cast(seg / kSampleCm)); + for (int i = (k == 1 ? 0 : 1); i <= n; ++i) { + const float t = static_cast(i) / static_cast(n); + const ue_wrap::FVector p{a.X + (b.X - a.X) * t, a.Y + (b.Y - a.Y) * t, a.Z + (b.Z - a.Z) * t}; + if (!fn(p, along + seg * t)) return false; + } + along += seg; + } + return true; +} + +// The floor under `p` within kDropCm above or below it; false when none is hit. +bool FloorUnder(void* player, const ue_wrap::FVector& p, ue_wrap::FVector* out) { + ue_wrap::trace::Hit hit; + if (!ue_wrap::trace::LineHitStatDyn(player, {p.X, p.Y, p.Z + kDropCm}, {p.X, p.Y, p.Z - kDropCm}, &hit) || + !hit.blocked) + return false; + *out = hit.point; + return true; +} + +} // namespace + +bool FloorStandable(void* player, const ue_wrap::FVector& at, float floorZ) { + ue_wrap::trace::Hit hit; + if (!ue_wrap::trace::LineHitStatDyn(player, {at.X, at.Y, at.Z + kProbeUpCm}, {at.X, at.Y, at.Z - kProbeDownCm}, + &hit) || + !hit.blocked) + return true; + return hit.normal.Z >= floorZ; +} + +bool SteepAhead(void* player, const ue_wrap::FVector& pos, const ue_wrap::FVector& toward, float floorZ, + ue_wrap::FVector* spot) { + const float h = Flat(pos, toward); + if (h < 1.f) return false; + const float reach = (std::min)(h + 50.f, kTraceAheadCm); + const ue_wrap::FVector a{pos.X, pos.Y, pos.Z + kKneeCm}; + const ue_wrap::FVector b{a.X + (toward.X - pos.X) / h * reach, a.Y + (toward.Y - pos.Y) / h * reach, a.Z}; + ue_wrap::trace::Hit hit; + if (!ue_wrap::trace::LineHitStatDyn(player, a, b, &hit) || !hit.blocked) return false; + if (hit.normal.Z >= floorZ || hit.normal.Z <= kWallNormalZ) return false; + *spot = hit.point; + return true; +} + +void Search::Begin(const ue_wrap::FVector& from, const ue_wrap::FVector& goal, const ue_wrap::FVector& steep, + float floorZ) { + size_t at = spots_.size(); + for (size_t i = 0; i < spots_.size(); ++i) + if (Flat(spots_[i], steep) <= kSpotClearCm && std::fabs(spots_[i].Z - steep.Z) <= kSpotLevelCm) at = i; + if (at == spots_.size()) { + if (spots_.size() == kMaxSpots) spots_.erase(spots_.begin()); + spots_.push_back(steep); + at = spots_.size() - 1; + } + BeginRound(from, goal, at, floorZ); +} + +void Search::BeginRound(const ue_wrap::FVector& from, const ue_wrap::FVector& goal, size_t spot, float floorZ) { + from_ = from; + goal_ = goal; + round_ = spots_[spot]; + floorZ_ = floorZ; + active_ = true; + next_ = 0; + best_.clear(); + bestScore_ = 0.f; + UE_LOGI("director/detour: round the spot (%.0f,%.0f,%.0f), too steep for the body -- %zu remembered", round_.X, + round_.Y, round_.Z, spots_.size()); +} + +int Search::PassedSpot(const std::vector& route) const { + int found = -1; + Walk(route, [&](const ue_wrap::FVector& p, float along) { + if (along <= kSpotClearCm) return true; + for (size_t i = 0; i < spots_.size(); ++i) + if (Flat(spots_[i], p) <= kSpotClearCm && std::fabs(spots_[i].Z - p.Z) <= kSpotLevelCm) { + found = static_cast(i); + return false; + } + return true; + }); + return found; +} + +bool Search::Holds(void* player, const ue_wrap::FVector& via, std::vector* toVia, + float* score) const { + // The way there: floor the body stands on all along, clear of every remembered spot past the + // stretch the walker stands in. + std::vector there; + if (!E::FindNavPath(player, from_, via, there) || there.size() < 2 || Flat(there.back(), via) > kViaReachCm) + return false; + if (PassedSpot(there) >= 0) return false; + if (!Walk(there, [&](const ue_wrap::FVector& p, float) { return FloorStandable(player, p, floorZ_); })) + return false; + // The way on: clear of every remembered spot, its floor sampled for its first stretch. + std::vector on; + if (!E::FindNavPath(player, via, goal_, on) || on.size() < 2) return false; + if (PassedSpot(on) >= 0) return false; + if (!Walk(on, [&](const ue_wrap::FVector& p, float along) { + return along > kOnwardCheckCm || FloorStandable(player, p, floorZ_); + })) + return false; + *score = Length(there) + Length(on) + Flat(on.back(), goal_); + *toVia = std::move(there); + return true; +} + +Search::Step Search::Advance(void* player, std::vector* route) { + if (!active_) return Step::Exhausted; + for (int k = 0; k < kPerAdvance && next_ < kRings * kDirections; ++k, ++next_) { + const int ring = next_ / kDirections, dir = next_ % kDirections; + const float a = 6.28318530718f * static_cast(dir) / static_cast(kDirections); + const ue_wrap::FVector c{round_.X + std::cos(a) * kRingsCm[ring], round_.Y + std::sin(a) * kRingsCm[ring], + round_.Z}; + ue_wrap::FVector floor{}; + std::vector toVia; + float score = 0.f; + if (FloorUnder(player, c, &floor) && + Holds(player, {floor.X, floor.Y, floor.Z + kViaLiftCm}, &toVia, &score) && + (best_.empty() || score < bestScore_)) { + best_ = std::move(toVia); + bestScore_ = score; + } + if (dir == kDirections - 1 && !best_.empty()) { // a ring done that held one: the nearest way round + active_ = false; + UE_LOGI("director/detour: a via point %.0f cm round the spot holds (route %.0f cm, %.0f cm to the goal " + "in all)", kRingsCm[ring], Length(best_), bestScore_); + *route = std::move(best_); + best_.clear(); + return Step::Found; + } + } + if (next_ < kRings * kDirections) return Step::Working; + active_ = false; + UE_LOGW("director/detour: no via point within %.0f cm of the spot holds -- back to grinding", + kRingsCm[kRings - 1]); + return Step::Exhausted; +} + +} // namespace coop::director::detour diff --git a/src/votv-coop/src/coop/dev/director/proc_walkgrab.cpp b/src/votv-coop/src/coop/dev/director/proc_walkgrab.cpp index f3bd6b9cd..6f7aa328a 100644 --- a/src/votv-coop/src/coop/dev/director/proc_walkgrab.cpp +++ b/src/votv-coop/src/coop/dev/director/proc_walkgrab.cpp @@ -14,6 +14,7 @@ // Every action is at the human-INPUT seam. All OnTick runs on the game thread // (inside the ControlManager's per-tick closure), so engine calls are direct. +#include "coop/dev/director/detour.h" #include "coop/dev/director/director.h" #include "ue_wrap/actors/prop.h" @@ -30,6 +31,7 @@ #include #include #include +#include #include namespace coop::director { @@ -55,6 +57,7 @@ constexpr float kDoorReachCm = 320.f; // a closed door within this of the stu constexpr int kMaxDoorOpens = 20; // grind: keep opening doors as needed (never-give-up rule) constexpr int kUnstickTicks = 70; // ~0.28 s of sideways juke to slide off a box before re-checking constexpr int kSettleTicks = 12; // ~0.25 s braking before the grab +constexpr int kMaxDetours = 12; // ways round a slope too steep for the body, per walk (detour.h) // A walk-to target is reached on its own level only: the walker's centre within this of the target's // height, less than a storey. A route whose end the navmesh put on the floor below counts as // horizontally there, 11 m under a door on the dish building's upper floor. @@ -202,6 +205,21 @@ class GotoProcess : public IProcess { return onLevel_ ? !Arrived(ctx, goal_) : HorizDist(ctx.pos, goal_.targetPos) > goal_.reachCm; } ProcStatus OnTick(const PlayerContext& ctx) override { + if (detour_.Active()) { // looking for a way round a slope the body cannot climb: stand while it does + std::vector route; + const detour::Search::Step step = detour_.Advance(ctx.player, &route); + if (step == detour::Search::Step::Working) return ProcStatus::Working; + if (step == detour::Search::Step::Found) { + // The via point is a leg boundary: from there the route to the goal is asked again. + waypoints_.assign(route.begin() + 1, route.end()); + goal_.route = std::move(route); + wp_ = 0; lastWp_ = 0; bestWp_ = 1e9f; bestPile_ = 1e9f; sinceProgress_ = 0; + pathed_ = true; partial_ = true; + } else { + jukeSign_ = -jukeSign_; // no way round held: back to grinding + unstickTicks_ = kUnstickTicks; + } + } if (!pathed_) { // compute the route (after the hand is clear, so from the real start), again per leg pathed_ = true; std::vector path; @@ -216,6 +234,16 @@ class GotoProcess : public IProcess { UE_LOGI("director/Goto: route %zu waypoints (%s), dist=%.0fcm", waypoints_.size(), ok ? "NavMesh route" : goal_.straight ? "straight line, as asked" : "straight-line fallback", HorizDist(ctx.pos, goal_.targetPos)); + if (floorZ_ < 0.f) { // the body's floor limit, read once: what the NavMesh's routes are held to + E::WalkLimits w; + floorZ_ = E::ReadWalkLimits(ctx.player, &w) ? w.floorZ : 0.f; + } + const int spot = ok && floorZ_ > 0.f && detours_ < kMaxDetours ? detour_.PassedSpot(goal_.route) : -1; + if (spot >= 0) { // the route climbs a slope this walk already found too steep: round it at once + ++detours_; + detour_.BeginRound(ctx.pos, goal_.targetPos, static_cast(spot), floorZ_); + return ProcStatus::Working; + } } const float toPile = HorizDist(ctx.pos, goal_.targetPos); while (wp_ + 1 < waypoints_.size() && HorizDist(ctx.pos, waypoints_[wp_]) < kAdvanceCm) ++wp_; @@ -236,6 +264,14 @@ class GotoProcess : public IProcess { sinceProgress_ = 0; // FIRST: a CLOSED door? Open it as a player's press would and keep walking through. if (doorOpens_ < kMaxDoorOpens && TryOpenBlockingDoor(ctx)) { ++doorOpens_; return ProcStatus::Working; } + // THEN: a slope too steep for the body, which the NavMesh routes over (detour.h)? Go round it. + ue_wrap::FVector steep{}; + if (detours_ < kMaxDetours && floorZ_ > 0.f && + detour::SteepAhead(ctx.player, ctx.pos, waypoints_[wp_], floorZ_, &steep)) { + ++detours_; + detour_.Begin(ctx.pos, goal_.targetPos, steep, floorZ_); + return ProcStatus::Working; + } // NEVER GIVE UP: grind past physics boxes and clutter. Alternate a sideways JUKE to // slide off the obstacle, and RE-PATH periodically. Goto returns Failed ONLY on a hard // engine problem -- reaching the pile (IsActive->false, Grab takes over) or the run @@ -314,6 +350,9 @@ class GotoProcess : public IProcess { int sinceProgress_ = 0, logTick_ = 0, doorOpens_ = 0; int stuckEpisodes_ = 0, unstickTicks_ = 0; float jukeSign_ = 1.f; + float floorZ_ = -1.f; // the body's lowest floor normal Z; -1 unread, 0 unreadable (no detours) + int detours_ = 0; + detour::Search detour_; }; // ---- GrabProcess ----------------------------------------------------------------------- From df1d4e8dd6a17fb764489fb0bf8f61a9476740e1 Mon Sep 17 00:00:00 2001 From: pelmentor Date: Sat, 10 Oct 2026 09:00:43 +0600 Subject: [PATCH 058/306] [dev] director: a way round that did not hold is not offered again The first full drive drill on a fresh New Game passed, but at its second steep spot the search offered the same via point eleven times: the walk toward it stopped at the same spot each time, and the walk went on only by the stuck handler's re-path, 273 s of a 300 s budget. Stopped at the spot the last way round was found for, the search now drops that via point for good, so each retry tries the next way round; a walk may take 24 detours, and the found line names the via point. Co-Authored-By: Claude Opus 5.5 --- .../include/coop/dev/director/detour.h | 10 +++++++- .../src/coop/dev/director/detour.cpp | 25 +++++++++++++++++-- .../src/coop/dev/director/proc_walkgrab.cpp | 2 +- 3 files changed, 33 insertions(+), 4 deletions(-) diff --git a/src/votv-coop/include/coop/dev/director/detour.h b/src/votv-coop/include/coop/dev/director/detour.h index 379d8e314..e380ed35c 100644 --- a/src/votv-coop/include/coop/dev/director/detour.h +++ b/src/votv-coop/include/coop/dev/director/detour.h @@ -29,7 +29,9 @@ bool SteepAhead(void* player, const ue_wrap::FVector& pos, const ue_wrap::FVecto class Search { public: - // Remembers the spot `steep` and starts a search round it, from `from` toward `goal`. + // Remembers the spot `steep` and starts a search round it, from `from` toward `goal`. Stopped at the + // same spot again after the last way round it was found, that way did not hold: its via point is + // never offered again, so each retry tries the next way round. void Begin(const ue_wrap::FVector& from, const ue_wrap::FVector& goal, const ue_wrap::FVector& steep, float floorZ); // Starts a search round a spot already remembered, the one `route` passes (PassedSpot). @@ -50,13 +52,19 @@ class Search { bool Holds(void* player, const ue_wrap::FVector& via, std::vector* toVia, float* score) const; + bool NearFailedVia(const ue_wrap::FVector& via) const; + std::vector spots_; + std::vector failedVias_; ue_wrap::FVector from_{}, goal_{}, round_{}; + ue_wrap::FVector lastVia_{}, lastRound_{}; + bool lastFound_ = false; // the last search found a way round lastRound_, by lastVia_ float floorZ_ = 0.f; bool active_ = false; int next_ = 0; // the next candidate: ring * kDirections + direction float bestScore_ = 0.f; std::vector best_; // the best held route of the ring under evaluation + ue_wrap::FVector bestVia_{}; }; } // namespace coop::director::detour diff --git a/src/votv-coop/src/coop/dev/director/detour.cpp b/src/votv-coop/src/coop/dev/director/detour.cpp index 47150c725..cf691db0b 100644 --- a/src/votv-coop/src/coop/dev/director/detour.cpp +++ b/src/votv-coop/src/coop/dev/director/detour.cpp @@ -32,6 +32,7 @@ constexpr float kTraceAheadCm = 200.f; // SteepAhead looks this far toward constexpr float kKneeCm = -50.f; // ...at knee height, under the walker's centre constexpr float kWallNormalZ = 0.3f; // a hit flatter than a wall: a slope, not a door, box or fence constexpr size_t kMaxSpots = 32; +constexpr float kFailedViaCm = 300.f; // a candidate this near a via point that did not hold is not one float Flat(const ue_wrap::FVector& a, const ue_wrap::FVector& b) { return std::hypot(a.X - b.X, a.Y - b.Y); } @@ -106,15 +107,29 @@ void Search::Begin(const ue_wrap::FVector& from, const ue_wrap::FVector& goal, c spots_.push_back(steep); at = spots_.size() - 1; } + if (lastFound_ && Flat(spots_[at], lastRound_) <= kSpotClearCm && + std::fabs(spots_[at].Z - lastRound_.Z) <= kSpotLevelCm) { + if (failedVias_.size() == kMaxSpots) failedVias_.erase(failedVias_.begin()); + failedVias_.push_back(lastVia_); + UE_LOGI("director/detour: the way round by (%.0f,%.0f,%.0f) did not hold -- not offered again", lastVia_.X, + lastVia_.Y, lastVia_.Z); + } BeginRound(from, goal, at, floorZ); } +bool Search::NearFailedVia(const ue_wrap::FVector& via) const { + for (const ue_wrap::FVector& f : failedVias_) + if (Flat(f, via) <= kFailedViaCm && std::fabs(f.Z - via.Z) <= kSpotLevelCm) return true; + return false; +} + void Search::BeginRound(const ue_wrap::FVector& from, const ue_wrap::FVector& goal, size_t spot, float floorZ) { from_ = from; goal_ = goal; round_ = spots_[spot]; floorZ_ = floorZ; active_ = true; + lastFound_ = false; next_ = 0; best_.clear(); bestScore_ = 0.f; @@ -140,6 +155,7 @@ bool Search::Holds(void* player, const ue_wrap::FVector& via, std::vector there; if (!E::FindNavPath(player, from_, via, there) || there.size() < 2 || Flat(there.back(), via) > kViaReachCm) return false; @@ -174,11 +190,16 @@ Search::Step Search::Advance(void* player, std::vector* route) (best_.empty() || score < bestScore_)) { best_ = std::move(toVia); bestScore_ = score; + bestVia_ = {floor.X, floor.Y, floor.Z + kViaLiftCm}; } if (dir == kDirections - 1 && !best_.empty()) { // a ring done that held one: the nearest way round active_ = false; - UE_LOGI("director/detour: a via point %.0f cm round the spot holds (route %.0f cm, %.0f cm to the goal " - "in all)", kRingsCm[ring], Length(best_), bestScore_); + lastFound_ = true; + lastVia_ = bestVia_; + lastRound_ = round_; + UE_LOGI("director/detour: a via point %.0f cm round the spot holds, at (%.0f,%.0f,%.0f) (route %.0f cm, " + "%.0f cm to the goal in all)", kRingsCm[ring], bestVia_.X, bestVia_.Y, bestVia_.Z, Length(best_), + bestScore_); *route = std::move(best_); best_.clear(); return Step::Found; diff --git a/src/votv-coop/src/coop/dev/director/proc_walkgrab.cpp b/src/votv-coop/src/coop/dev/director/proc_walkgrab.cpp index 6f7aa328a..61ed0dba9 100644 --- a/src/votv-coop/src/coop/dev/director/proc_walkgrab.cpp +++ b/src/votv-coop/src/coop/dev/director/proc_walkgrab.cpp @@ -57,7 +57,7 @@ constexpr float kDoorReachCm = 320.f; // a closed door within this of the stu constexpr int kMaxDoorOpens = 20; // grind: keep opening doors as needed (never-give-up rule) constexpr int kUnstickTicks = 70; // ~0.28 s of sideways juke to slide off a box before re-checking constexpr int kSettleTicks = 12; // ~0.25 s braking before the grab -constexpr int kMaxDetours = 12; // ways round a slope too steep for the body, per walk (detour.h) +constexpr int kMaxDetours = 24; // ways round a slope too steep for the body, per walk (detour.h) // A walk-to target is reached on its own level only: the walker's centre within this of the target's // height, less than a storey. A route whose end the navmesh put on the floor below counts as // horizontally there, 11 m under a door on the dish building's upper floor. From 9373601e83df284013ef52518b65a99ecee15f6a Mon Sep 17 00:00:00 2001 From: pelmentor Date: Sat, 10 Oct 2026 09:10:24 +0600 Subject: [PATCH 059/306] [dev] drills ask reachability in legs, through the director The server drill abandoned on a fresh New Game: one path query toward a box 790 m away stops on a tile boundary, so no route reached its bay. Four more pickers (the server-upgrade, container-view, extinguisher and keypad drills) asked the same single query, and the keypad drill walked toward the nearest keypad whose route stopped short. Each now asks the director's RouteInLegs, whose bound of legs is its own, and the keypad drill's stop-short fallback is deleted. Co-Authored-By: Claude Opus 5.5 --- .../include/coop/dev/director/standpoints.h | 7 +++--- .../src/coop/dev/container_view_drill.cpp | 7 ++---- .../src/coop/dev/director/standpoints.cpp | 6 ++--- src/votv-coop/src/coop/dev/fireext_drill.cpp | 7 ++---- src/votv-coop/src/coop/dev/keypad_drill.cpp | 25 +++---------------- src/votv-coop/src/coop/dev/server_drill.cpp | 12 +++------ .../src/coop/dev/server_upgrade_drill.cpp | 8 ++---- 7 files changed, 20 insertions(+), 52 deletions(-) diff --git a/src/votv-coop/include/coop/dev/director/standpoints.h b/src/votv-coop/include/coop/dev/director/standpoints.h index 9f4e103d4..9c9d88172 100644 --- a/src/votv-coop/include/coop/dev/director/standpoints.h +++ b/src/votv-coop/include/coop/dev/director/standpoints.h @@ -13,9 +13,10 @@ namespace coop::director { // query spends a bounded search and returns a PARTIAL route on a long walk -- measured on a fresh New Game, whose // players start about 790 m from the base and whose route to it stopped on a 2048-cm tile boundary -- so each leg asks // again from the last one's end, as the walker does when it reaches it (GotoProcess). False when a leg finds no route -// or ends no closer to `to` than it began, or after `maxLegs`. `outLength`, when given, is the route's flat length. -bool RouteInLegs(void* player, const ue_wrap::FVector& from, const ue_wrap::FVector& to, float reachCm, int maxLegs, - float* outLength); +// or ends no closer to `to` than it began, or after a bound of legs. `outLength`, when given, is the route's flat +// length. Every drill's reachability question: a single path query refuses whatever lies a long walk away. +bool RouteInLegs(void* player, const ue_wrap::FVector& from, const ue_wrap::FVector& to, float reachCm, + float* outLength = nullptr); // The `count` points of a ring of `ringCm` about `about`, at the height `player` stands at, that a route from `player` // reaches (RouteInLegs), shortest route first: the director's own test for a reachable pile (PickReachablePile). diff --git a/src/votv-coop/src/coop/dev/container_view_drill.cpp b/src/votv-coop/src/coop/dev/container_view_drill.cpp index ef01f382c..f941656f6 100644 --- a/src/votv-coop/src/coop/dev/container_view_drill.cpp +++ b/src/votv-coop/src/coop/dev/container_view_drill.cpp @@ -5,6 +5,7 @@ #include "coop/config/config.h" #include "coop/config/config_registry.h" #include "coop/dev/director/director.h" +#include "coop/dev/director/standpoints.h" #include "coop/net/session.h" #include "coop/player/players_registry.h" #include "coop/player/roster.h" @@ -135,12 +136,8 @@ bool PickAtv(void* player, const FVector& at, DR::DirectorGoal& goal, float& len if (g.cands.size() > static_cast(kCandidates)) g.cands.resize(kCandidates); float best = 1e30f; for (const Cand& c : g.cands) { - std::vector path; - if (!E::FindNavPath(player, at, c.pos, path) || path.empty()) continue; - if (HorizDist(path.back(), c.pos) > kStandCm) continue; float len = 0.f; - for (size_t i = 1; i < path.size(); ++i) len += HorizDist(path[i - 1], path[i]); - if (len >= best) continue; + if (!coop::director::RouteInLegs(player, at, c.pos, kStandCm, &len) || len >= best) continue; best = len; goal.targetActor = c.atv; goal.targetPos = c.pos; diff --git a/src/votv-coop/src/coop/dev/director/standpoints.cpp b/src/votv-coop/src/coop/dev/director/standpoints.cpp index 5962b2261..c905738b6 100644 --- a/src/votv-coop/src/coop/dev/director/standpoints.cpp +++ b/src/votv-coop/src/coop/dev/director/standpoints.cpp @@ -21,11 +21,11 @@ constexpr float kMinLegProgressCm = 100.f; // a leg that ends no nearer than th } // namespace -bool RouteInLegs(void* player, const ue_wrap::FVector& from, const ue_wrap::FVector& to, float reachCm, int maxLegs, +bool RouteInLegs(void* player, const ue_wrap::FVector& from, const ue_wrap::FVector& to, float reachCm, float* outLength) { ue_wrap::FVector cur = from; float len = 0.f; - for (int leg = 0; leg < maxLegs; ++leg) { + for (int leg = 0; leg < kMaxLegs; ++leg) { std::vector route; if (!E::FindNavPath(player, cur, to, route) || route.empty()) return false; for (size_t k = 1; k < route.size(); ++k) len += Flat(route[k - 1], route[k]); @@ -51,7 +51,7 @@ std::vector ReachableStandpoints(void* player, const ue_wrap:: p.X = about.X + ringCm * std::cos(a); p.Y = about.Y + ringCm * std::sin(a); float len = 0.f; - if (!RouteInLegs(player, from, p, reachCm, kMaxLegs, &len)) continue; + if (!RouteInLegs(player, from, p, reachCm, &len)) continue; found.emplace_back(len, p); } std::stable_sort(found.begin(), found.end(), [](const auto& x, const auto& y) { return x.first < y.first; }); diff --git a/src/votv-coop/src/coop/dev/fireext_drill.cpp b/src/votv-coop/src/coop/dev/fireext_drill.cpp index fc2b18909..af74de857 100644 --- a/src/votv-coop/src/coop/dev/fireext_drill.cpp +++ b/src/votv-coop/src/coop/dev/fireext_drill.cpp @@ -5,6 +5,7 @@ #include "coop/config/config.h" #include "coop/dev/director/aimed_grab.h" #include "coop/dev/director/director.h" +#include "coop/dev/director/standpoints.h" #include "coop/net/session.h" #include "coop/player/local_streams.h" // CurrentHoldGen: the hold the stale tail names #include "coop/player/players_registry.h" @@ -396,12 +397,8 @@ bool PickTarget(void* player, const char*& why) { ++unread; continue; } - std::vector route; - if (!E::FindNavPath(player, me, at, route) || route.empty()) continue; - const ue_wrap::FVector& end = route.back(); - if (std::hypot(end.X - at.X, end.Y - at.Y) > kRouteEndReachCm) continue; float len = 0.f; - for (size_t i = 1; i < route.size(); ++i) len += Dist(route[i - 1], route[i]); + if (!coop::director::RouteInLegs(player, me, at, kRouteEndReachCm, &len)) continue; if (len < best) { best = len; g_target.Set(o); g_targetKey = w.key; bestAt = at; } } if (unread > 0) diff --git a/src/votv-coop/src/coop/dev/keypad_drill.cpp b/src/votv-coop/src/coop/dev/keypad_drill.cpp index f91ea5102..ff937616e 100644 --- a/src/votv-coop/src/coop/dev/keypad_drill.cpp +++ b/src/votv-coop/src/coop/dev/keypad_drill.cpp @@ -4,6 +4,7 @@ #include "coop/config/config.h" #include "coop/dev/director/director.h" +#include "coop/dev/director/standpoints.h" #include "coop/interactables/keypad_sync.h" // the keypad lane's key #include "coop/net/session.h" #include "coop/player/players_registry.h" @@ -136,9 +137,8 @@ bool Typeable(const std::wstring& code) { // The keypad this peer's navmesh reaches by the shortest walk, among keypads the lane names that gate // a door: a route asked for the keypad itself ends on the floor below it, where a player stands to use -// it. From far off every route stops short at the search's node limit, and then the nearest keypad a -// route heads for is walked to anyway: the director re-paths on the way. The walk ends within -// kStandCm of the keypad, on its level. +// it. From far off a single route stops short at the search's node limit, so reachability is asked +// leg by leg (the director's RouteInLegs). The walk ends within kStandCm of the keypad, on its level. bool PickReachableKeypad(void* player, const ue_wrap::FVector& at, DR::DirectorGoal& goal, float& lenOut) { struct Cand { void* lock; ue_wrap::FVector pos; float dist; }; std::vector cands; @@ -156,30 +156,13 @@ bool PickReachableKeypad(void* player, const ue_wrap::FVector& at, DR::DirectorG std::sort(cands.begin(), cands.end(), [](const Cand& a, const Cand& b) { return a.dist < b.dist; }); if (cands.size() > static_cast(kCandidates)) cands.resize(kCandidates); float best = 1e30f; - int noRoute = 0, endsFar = 0; - const Cand* nearestRouted = nullptr; - float nearestRoutedLen = 0.f; for (const Cand& c : cands) { - std::vector path; - if (!E::FindNavPath(player, at, c.pos, path) || path.empty()) { ++noRoute; continue; } float len = 0.f; - for (size_t i = 1; i < path.size(); ++i) len += HorizDist(path[i - 1], path[i]); - if (!nearestRouted) { // candidates run nearest first - nearestRouted = &c; - nearestRoutedLen = len; - } - if (HorizDist(path.back(), c.pos) > kStandCm) { ++endsFar; continue; } - if (len >= best) continue; + if (!coop::director::RouteInLegs(player, at, c.pos, kStandCm, &len) || len >= best) continue; best = len; goal.targetActor = c.lock; lenOut = len; } - if (!goal.targetActor && nearestRouted) { - goal.targetActor = nearestRouted->lock; - lenOut = nearestRouted->dist > nearestRoutedLen ? nearestRouted->dist : nearestRoutedLen; - UE_LOGI("[KEYPAD-DRILL] client: every route of %zu stops short of its keypad (%d with no route); walking " - "to the nearest, %.0fcm off, and re-pathing on the way", cands.size(), noRoute, nearestRouted->dist); - } if (!goal.targetActor) { UE_LOGW("[KEYPAD-DRILL] client at (%.0f,%.0f,%.0f): %zu keypad(s) gate a door and are named, none with a " "route", at.X, at.Y, at.Z, cands.size()); diff --git a/src/votv-coop/src/coop/dev/server_drill.cpp b/src/votv-coop/src/coop/dev/server_drill.cpp index f72f1325f..f6dcf94df 100644 --- a/src/votv-coop/src/coop/dev/server_drill.cpp +++ b/src/votv-coop/src/coop/dev/server_drill.cpp @@ -4,6 +4,7 @@ #include "coop/config/config.h" #include "coop/dev/director/director.h" +#include "coop/dev/director/standpoints.h" #include "coop/interactables/serverbox_sync.h" #include "coop/net/session.h" #include "coop/player/players_registry.h" @@ -16,11 +17,9 @@ #include "ue_wrap/devices/serverbox.h" #include "ue_wrap/engine/engine.h" #include "ue_wrap/engine/engine_component.h" -#include "ue_wrap/engine/engine_nav.h" #include -#include #include #include #include @@ -77,12 +76,8 @@ void CGo(CStep s) { g_client = s; g_stepMs = ::GetTickCount64(); } bool Resolved() { return SB::EnsureBreakResolved() && SB::EnsureRepairResolved(); } -float Flat(const ue_wrap::FVector& a, const ue_wrap::FVector& b) { - const float dx = a.X - b.X, dy = a.Y - b.Y; - return std::sqrt(dx * dx + dy * dy); -} -// HOST: the first healthy box with no upgrades and a bay a player can walk to breaks on its own verb. +// HOST: the first healthy box with no upgrades and a bay (its take-out component) breaks on its own verb. bool BreakOne() { std::vector servers; SB::ReadServers(servers); @@ -217,13 +212,12 @@ void ClientTick(void* player) { } ue_wrap::FVector at{}, bay{}; void* comp = SB::TakeOutComponent(g_box.Get()); - std::vector route; if (!comp || !E::TryGetActorLocation(player, at)) { Abandon("the box's bay or the player's place is unread"); return; } bay = E::GetComponentLocation(comp); - if (!E::FindNavPath(player, at, bay, route) || route.empty() || Flat(route.back(), bay) > kBayReachCm) { + if (!coop::director::RouteInLegs(player, at, bay, kBayReachCm)) { Abandon("no NavMesh route reaches the box's bay"); return; } diff --git a/src/votv-coop/src/coop/dev/server_upgrade_drill.cpp b/src/votv-coop/src/coop/dev/server_upgrade_drill.cpp index c4619505a..4bb8f67a9 100644 --- a/src/votv-coop/src/coop/dev/server_upgrade_drill.cpp +++ b/src/votv-coop/src/coop/dev/server_upgrade_drill.cpp @@ -5,6 +5,7 @@ #include "coop/config/config.h" #include "coop/dev/director/aim_fan.h" #include "coop/dev/director/director.h" +#include "coop/dev/director/standpoints.h" #include "coop/interactables/server_upgrade_sync.h" #include "coop/net/session.h" #include "coop/player/players_registry.h" @@ -19,7 +20,6 @@ #include "ue_wrap/engine/engine.h" #include "ue_wrap/engine/engine_component.h" #include "ue_wrap/engine/engine_mainplayer.h" -#include "ue_wrap/engine/engine_nav.h" #include @@ -129,12 +129,8 @@ bool PickBox(void* player, ue_wrap::FVector& bayOut) { if (cands.size() > kMaxCandidates) cands.resize(kMaxCandidates); float bestLen = 1e30f; for (const Cand& c : cands) { - std::vector route; - if (!E::FindNavPath(player, at, c.bay, route) || route.empty()) continue; - if (Flat(route.back(), c.bay) > kBayReachCm) continue; float len = 0.f; - for (size_t k = 1; k < route.size(); ++k) len += Flat(route[k - 1], route[k]); - if (len >= bestLen) continue; + if (!coop::director::RouteInLegs(player, at, c.bay, kBayReachCm, &len) || len >= bestLen) continue; bestLen = len; g_boxIdx = static_cast(c.idx); g_level0 = c.lvl; From 87ddaaaa53a6dd477ba2052d344cacd9000afffd Mon Sep 17 00:00:00 2001 From: pelmentor Date: Sat, 10 Oct 2026 09:27:47 +0600 Subject: [PATCH 060/306] [dev] director: a way round is the route it held, and no leg loops The server drill looped on a fresh New Game: the search offered a via point 25 cm from the walker, whose fresh route at once passed the spot again, until the deadline. A via point now lies 3 m out at least, and the walker follows the very route the search held, to it and on, not a fresh one; a partial route ending no nearer goes to the stuck handler, a spot whose search held nothing is not searched again, and a failed query mid-walk stands instead of beelining. A ring's legs are walked once, a search spends a budget of samples per tick, the steep test runs before the door scan, and the hit read uses a stack buffer. Co-Authored-By: Claude Opus 5.5 --- .../include/coop/dev/director/detour.h | 25 ++++--- .../include/coop/dev/director/standpoints.h | 9 ++- src/votv-coop/include/ue_wrap/core/trace.h | 7 +- .../src/coop/dev/director/detour.cpp | 74 +++++++++++-------- .../src/coop/dev/director/proc_walkgrab.cpp | 54 +++++++++----- .../src/coop/dev/director/standpoints.cpp | 43 ++++++++--- .../src/coop/dev/drive_drill_verbs.cpp | 3 +- .../src/coop/dev/drive_drill_verbs.h | 2 - src/votv-coop/src/ue_wrap/core/trace.cpp | 23 +++--- .../src/ue_wrap/engine/engine_nav.cpp | 6 +- 10 files changed, 152 insertions(+), 94 deletions(-) diff --git a/src/votv-coop/include/coop/dev/director/detour.h b/src/votv-coop/include/coop/dev/director/detour.h index e380ed35c..1887602c2 100644 --- a/src/votv-coop/include/coop/dev/director/detour.h +++ b/src/votv-coop/include/coop/dev/director/detour.h @@ -29,17 +29,19 @@ bool SteepAhead(void* player, const ue_wrap::FVector& pos, const ue_wrap::FVecto class Search { public: - // Remembers the spot `steep` and starts a search round it, from `from` toward `goal`. Stopped at the - // same spot again after the last way round it was found, that way did not hold: its via point is - // never offered again, so each retry tries the next way round. - void Begin(const ue_wrap::FVector& from, const ue_wrap::FVector& goal, const ue_wrap::FVector& steep, + // Remembers the spot `steep` and starts a search round it, from `from` toward `goal`. False, and no + // search, round a spot whose last search was exhausted: the stuck handler grinds there instead. + bool Begin(const ue_wrap::FVector& from, const ue_wrap::FVector& goal, const ue_wrap::FVector& steep, float floorZ); - // Starts a search round a spot already remembered, the one `route` passes (PassedSpot). - void BeginRound(const ue_wrap::FVector& from, const ue_wrap::FVector& goal, size_t spot, float floorZ); + // Starts a search round a spot already remembered, the one a route passes (PassedSpot). Either entry, + // round the spot the last way round was found for, says that way did not hold: its via point is + // never offered again, so each retry tries the next way round. + bool BeginRound(const ue_wrap::FVector& from, const ue_wrap::FVector& goal, size_t spot, float floorZ); enum class Step { Working, Found, Exhausted }; - // A few candidates per call, each a route query and its floor samples. On Found, `route` is the - // way to the via point, its first point the walker's start. Exhausted: no candidate held. + // Candidates within a budget of floor samples per call, each a route query and its samples. On + // Found, `route` is the way the search held: to the via point and on from it as far as the onward + // route goes, its first point the walker's start. Exhausted: no candidate held. Step Advance(void* player, std::vector* route); // The remembered spot a route passes within clearance of, past its first stretch, or -1. @@ -49,19 +51,22 @@ class Search { size_t Spots() const { return spots_.size(); } private: - bool Holds(void* player, const ue_wrap::FVector& via, std::vector* toVia, - float* score) const; + bool Holds(void* player, const ue_wrap::FVector& via, std::vector* walk, float* score); + bool Standable(void* player, const ue_wrap::FVector& at); bool NearFailedVia(const ue_wrap::FVector& via) const; std::vector spots_; + std::vector exhausted_; // per spot: its last search held no way round std::vector failedVias_; ue_wrap::FVector from_{}, goal_{}, round_{}; + size_t roundSpot_ = 0; ue_wrap::FVector lastVia_{}, lastRound_{}; bool lastFound_ = false; // the last search found a way round lastRound_, by lastVia_ float floorZ_ = 0.f; bool active_ = false; int next_ = 0; // the next candidate: ring * kDirections + direction + int samples_ = 0; // floor samples taken in this Advance float bestScore_ = 0.f; std::vector best_; // the best held route of the ring under evaluation ue_wrap::FVector bestVia_{}; diff --git a/src/votv-coop/include/coop/dev/director/standpoints.h b/src/votv-coop/include/coop/dev/director/standpoints.h index 9c9d88172..e6e5ee57f 100644 --- a/src/votv-coop/include/coop/dev/director/standpoints.h +++ b/src/votv-coop/include/coop/dev/director/standpoints.h @@ -13,14 +13,15 @@ namespace coop::director { // query spends a bounded search and returns a PARTIAL route on a long walk -- measured on a fresh New Game, whose // players start about 790 m from the base and whose route to it stopped on a 2048-cm tile boundary -- so each leg asks // again from the last one's end, as the walker does when it reaches it (GotoProcess). False when a leg finds no route -// or ends no closer to `to` than it began, or after a bound of legs. `outLength`, when given, is the route's flat -// length. Every drill's reachability question: a single path query refuses whatever lies a long walk away. +// or ends less than a metre closer to `to` than it began (Detour ends a partial route at the polygon nearest the goal, +// so a winding approach still ends nearer), or after 16 legs. `outLength`, when given, is the route's flat length. +// Every drill's reachability question: a single path query refuses whatever lies a long walk away. bool RouteInLegs(void* player, const ue_wrap::FVector& from, const ue_wrap::FVector& to, float reachCm, float* outLength = nullptr); // The `count` points of a ring of `ringCm` about `about`, at the height `player` stands at, that a route from `player` -// reaches (RouteInLegs), shortest route first: the director's own test for a reachable pile (PickReachablePile). -// Empty when `player` does not read or no route reaches one. +// reaches, shortest route first: the legs toward the ring's centre walked once (RouteInLegs' rule), each point asked +// from the start of the leg that reaches the ring. Empty when `player` does not read or no route reaches one. std::vector ReachableStandpoints(void* player, const ue_wrap::FVector& about, float ringCm, int count, float reachCm); diff --git a/src/votv-coop/include/ue_wrap/core/trace.h b/src/votv-coop/include/ue_wrap/core/trace.h index 71692c918..9f61f7a54 100644 --- a/src/votv-coop/include/ue_wrap/core/trace.h +++ b/src/votv-coop/include/ue_wrap/core/trace.h @@ -3,8 +3,8 @@ // layer: wraps the KSL CDO/UFunction resolve + the ParamFrame plumbing of a world // line trace. NO network logic, NO gameplay state. // -// One owner of the trace primitive, with three consumers: wisp.cpp's canReach parity -// trace, nameplate occlusion, and the director's stuck line, which names what it hit. +// One owner of the trace primitive: wisp.cpp's canReach parity trace, nameplate +// occlusion, and the director's stuck line and slope detour, which read what it hit. #pragma once @@ -24,7 +24,8 @@ namespace ue_wrap::trace { // Game thread only (dispatches a UFunction). int LineBlockedStatDyn(void* worldCtx, const FVector& start, const FVector& end); -// What the same trace hits first: the hit's actor and component (live, or null), and its +// What the same trace hits first (static and dynamic geometry; a physics body is not in the +// set): the hit's actor and component (live, or null), and its // impact point and normal, read from the call's FHitResult by the struct's reflected member // offsets. False when unresolvable (as -1 above); `out->blocked` false on a clear line. // Game thread only. diff --git a/src/votv-coop/src/coop/dev/director/detour.cpp b/src/votv-coop/src/coop/dev/director/detour.cpp index cf691db0b..0c220c493 100644 --- a/src/votv-coop/src/coop/dev/director/detour.cpp +++ b/src/votv-coop/src/coop/dev/director/detour.cpp @@ -18,7 +18,8 @@ namespace E = ue_wrap::engine; constexpr float kRingsCm[] = {600.f, 1200.f, 2400.f, 4800.f}; // nearest first: the shortest way round constexpr int kRings = sizeof(kRingsCm) / sizeof(kRingsCm[0]); constexpr int kDirections = 12; -constexpr int kPerAdvance = 2; // candidates per call, a route query and its samples each +constexpr int kSampleBudget = 120; // floor samples per call: a candidate past it waits for the next tick +constexpr float kMinViaCm = 300.f; // a via point at the walker's feet is no way round constexpr float kSampleCm = 100.f; // floor samples along a route, about a stride apart constexpr float kSpotClearCm = 300.f; // a route keeps this far from a remembered spot constexpr float kSpotLevelCm = 300.f; // ...on its level: a spot on a slope below is not one above @@ -97,24 +98,21 @@ bool SteepAhead(void* player, const ue_wrap::FVector& pos, const ue_wrap::FVecto return true; } -void Search::Begin(const ue_wrap::FVector& from, const ue_wrap::FVector& goal, const ue_wrap::FVector& steep, +bool Search::Begin(const ue_wrap::FVector& from, const ue_wrap::FVector& goal, const ue_wrap::FVector& steep, float floorZ) { size_t at = spots_.size(); for (size_t i = 0; i < spots_.size(); ++i) if (Flat(spots_[i], steep) <= kSpotClearCm && std::fabs(spots_[i].Z - steep.Z) <= kSpotLevelCm) at = i; if (at == spots_.size()) { - if (spots_.size() == kMaxSpots) spots_.erase(spots_.begin()); + if (spots_.size() == kMaxSpots) { + spots_.erase(spots_.begin()); + exhausted_.erase(exhausted_.begin()); + } spots_.push_back(steep); + exhausted_.push_back(false); at = spots_.size() - 1; } - if (lastFound_ && Flat(spots_[at], lastRound_) <= kSpotClearCm && - std::fabs(spots_[at].Z - lastRound_.Z) <= kSpotLevelCm) { - if (failedVias_.size() == kMaxSpots) failedVias_.erase(failedVias_.begin()); - failedVias_.push_back(lastVia_); - UE_LOGI("director/detour: the way round by (%.0f,%.0f,%.0f) did not hold -- not offered again", lastVia_.X, - lastVia_.Y, lastVia_.Z); - } - BeginRound(from, goal, at, floorZ); + return BeginRound(from, goal, at, floorZ); } bool Search::NearFailedVia(const ue_wrap::FVector& via) const { @@ -123,10 +121,19 @@ bool Search::NearFailedVia(const ue_wrap::FVector& via) const { return false; } -void Search::BeginRound(const ue_wrap::FVector& from, const ue_wrap::FVector& goal, size_t spot, float floorZ) { +bool Search::BeginRound(const ue_wrap::FVector& from, const ue_wrap::FVector& goal, size_t spot, float floorZ) { + if (exhausted_[spot]) return false; + if (lastFound_ && Flat(spots_[spot], lastRound_) <= kSpotClearCm && + std::fabs(spots_[spot].Z - lastRound_.Z) <= kSpotLevelCm) { + if (failedVias_.size() == kMaxSpots) failedVias_.erase(failedVias_.begin()); + failedVias_.push_back(lastVia_); + UE_LOGI("director/detour: the way round by (%.0f,%.0f,%.0f) did not hold -- not offered again", lastVia_.X, + lastVia_.Y, lastVia_.Z); + } from_ = from; goal_ = goal; round_ = spots_[spot]; + roundSpot_ = spot; floorZ_ = floorZ; active_ = true; lastFound_ = false; @@ -135,6 +142,7 @@ void Search::BeginRound(const ue_wrap::FVector& from, const ue_wrap::FVector& go bestScore_ = 0.f; UE_LOGI("director/detour: round the spot (%.0f,%.0f,%.0f), too steep for the body -- %zu remembered", round_.X, round_.Y, round_.Z, spots_.size()); + return true; } int Search::PassedSpot(const std::vector& route) const { @@ -151,55 +159,59 @@ int Search::PassedSpot(const std::vector& route) const { return found; } -bool Search::Holds(void* player, const ue_wrap::FVector& via, std::vector* toVia, - float* score) const { +bool Search::Standable(void* player, const ue_wrap::FVector& at) { + ++samples_; + return FloorStandable(player, at, floorZ_); +} + +bool Search::Holds(void* player, const ue_wrap::FVector& via, std::vector* walk, float* score) { + if (Flat(via, from_) < kMinViaCm || NearFailedVia(via)) return false; // The way there: floor the body stands on all along, clear of every remembered spot past the // stretch the walker stands in. - if (NearFailedVia(via)) return false; std::vector there; if (!E::FindNavPath(player, from_, via, there) || there.size() < 2 || Flat(there.back(), via) > kViaReachCm) return false; if (PassedSpot(there) >= 0) return false; - if (!Walk(there, [&](const ue_wrap::FVector& p, float) { return FloorStandable(player, p, floorZ_); })) - return false; - // The way on: clear of every remembered spot, its floor sampled for its first stretch. + if (!Walk(there, [&](const ue_wrap::FVector& p, float) { return Standable(player, p); })) return false; + // The way on: clear of every remembered spot, its floor sampled for its first stretch. The walker + // follows this very route, not a fresh one asked where it stands at the via point. std::vector on; if (!E::FindNavPath(player, via, goal_, on) || on.size() < 2) return false; if (PassedSpot(on) >= 0) return false; - if (!Walk(on, [&](const ue_wrap::FVector& p, float along) { - return along > kOnwardCheckCm || FloorStandable(player, p, floorZ_); - })) + if (!Walk(on, [&](const ue_wrap::FVector& p, float along) { return along > kOnwardCheckCm || Standable(player, p); })) return false; *score = Length(there) + Length(on) + Flat(on.back(), goal_); - *toVia = std::move(there); + there.insert(there.end(), on.begin() + 1, on.end()); + *walk = std::move(there); return true; } Search::Step Search::Advance(void* player, std::vector* route) { if (!active_) return Step::Exhausted; - for (int k = 0; k < kPerAdvance && next_ < kRings * kDirections; ++k, ++next_) { + samples_ = 0; + for (int k = 0; next_ < kRings * kDirections && (k == 0 || samples_ < kSampleBudget); ++k, ++next_) { const int ring = next_ / kDirections, dir = next_ % kDirections; const float a = 6.28318530718f * static_cast(dir) / static_cast(kDirections); const ue_wrap::FVector c{round_.X + std::cos(a) * kRingsCm[ring], round_.Y + std::sin(a) * kRingsCm[ring], round_.Z}; ue_wrap::FVector floor{}; - std::vector toVia; + std::vector walk; float score = 0.f; if (FloorUnder(player, c, &floor) && - Holds(player, {floor.X, floor.Y, floor.Z + kViaLiftCm}, &toVia, &score) && + Holds(player, {floor.X, floor.Y, floor.Z + kViaLiftCm}, &walk, &score) && (best_.empty() || score < bestScore_)) { - best_ = std::move(toVia); + best_ = std::move(walk); bestScore_ = score; bestVia_ = {floor.X, floor.Y, floor.Z + kViaLiftCm}; } - if (dir == kDirections - 1 && !best_.empty()) { // a ring done that held one: the nearest way round + if (dir == kDirections - 1 && !best_.empty()) { // the best of the nearest ring that holds one active_ = false; lastFound_ = true; lastVia_ = bestVia_; lastRound_ = round_; - UE_LOGI("director/detour: a via point %.0f cm round the spot holds, at (%.0f,%.0f,%.0f) (route %.0f cm, " - "%.0f cm to the goal in all)", kRingsCm[ring], bestVia_.X, bestVia_.Y, bestVia_.Z, Length(best_), - bestScore_); + UE_LOGI("director/detour: a via point %.0f cm round the spot holds, at (%.0f,%.0f,%.0f) (the held way %.0f " + "cm, %.0f cm to the goal in all)", kRingsCm[ring], bestVia_.X, bestVia_.Y, bestVia_.Z, + Length(best_), bestScore_); *route = std::move(best_); best_.clear(); return Step::Found; @@ -207,6 +219,8 @@ Search::Step Search::Advance(void* player, std::vector* route) } if (next_ < kRings * kDirections) return Step::Working; active_ = false; + // The spot may have shifted in the list (the oldest dropped at the cap); mark it only where it still is. + if (roundSpot_ < exhausted_.size() && Flat(spots_[roundSpot_], round_) < 1.f) exhausted_[roundSpot_] = true; UE_LOGW("director/detour: no via point within %.0f cm of the spot holds -- back to grinding", kRingsCm[kRings - 1]); return Step::Exhausted; diff --git a/src/votv-coop/src/coop/dev/director/proc_walkgrab.cpp b/src/votv-coop/src/coop/dev/director/proc_walkgrab.cpp index 61ed0dba9..c6761f206 100644 --- a/src/votv-coop/src/coop/dev/director/proc_walkgrab.cpp +++ b/src/votv-coop/src/coop/dev/director/proc_walkgrab.cpp @@ -1,13 +1,13 @@ // coop/dev/director/proc_walkgrab.cpp -- the walked-grab process set (Baritone -// IBaritoneProcess analogs). Three decision units the brain arbitrates by priority -// and context-activation: -// +// IBaritoneProcess analogs), three units the brain arbitrates by priority: // ClearHandProcess (prio 100), while the hand is FULL: drop the held prop at the // input seam (InpActEvt_drop), effect-seam backstop if the stub is inert. It // PREEMPTS the other two -- the bot cannot walk while holding a prop. -// GotoProcess (prio 50), while out of reach: FindPath over the baked NavMesh and -// per-tick AddMovementInput steering along the waypoints, with waypoint-progress -// stuck detection (distance-to-pile alone false-fails a winding route). +// GotoProcess (prio 50), while out of reach: FindPath over the baked NavMesh, a +// partial route walked as a leg and asked again from its end, and per-tick +// AddMovementInput steering along the waypoints, with waypoint-progress stuck +// detection (distance-to-pile alone false-fails a winding route); a slope too +// steep for the body is walked round (director/detour). // GrabProcess (prio 40), while in reach and not yet grabbed: settle, aim, then the // proven chippile grab (force lookAtActor + InpActEvt_use + playerGrabbed). // @@ -58,6 +58,7 @@ constexpr int kMaxDoorOpens = 20; // grind: keep opening doors as needed constexpr int kUnstickTicks = 70; // ~0.28 s of sideways juke to slide off a box before re-checking constexpr int kSettleTicks = 12; // ~0.25 s braking before the grab constexpr int kMaxDetours = 24; // ways round a slope too steep for the body, per walk (detour.h) +constexpr float kMinLegCm = 100.f; // a leg that ends no nearer the goal than this is not a leg: the stuck handler's // A walk-to target is reached on its own level only: the walker's centre within this of the target's // height, less than a storey. A route whose end the navmesh put on the floor below counts as // horizontally there, 11 m under a door on the dish building's upper floor. @@ -210,11 +211,13 @@ class GotoProcess : public IProcess { const detour::Search::Step step = detour_.Advance(ctx.player, &route); if (step == detour::Search::Step::Working) return ProcStatus::Working; if (step == detour::Search::Step::Found) { - // The via point is a leg boundary: from there the route to the goal is asked again. + // The held way, to the via point and on: a leg like any route, re-pathed at its end when short. waypoints_.assign(route.begin() + 1, route.end()); + partial_ = HorizDist(waypoints_.back(), goal_.targetPos) > goal_.reachCm; + legFrom_ = HorizDist(ctx.pos, goal_.targetPos); goal_.route = std::move(route); wp_ = 0; lastWp_ = 0; bestWp_ = 1e9f; bestPile_ = 1e9f; sinceProgress_ = 0; - pathed_ = true; partial_ = true; + pathed_ = true; } else { jukeSign_ = -jukeSign_; // no way round held: back to grinding unstickTicks_ = kUnstickTicks; @@ -224,24 +227,28 @@ class GotoProcess : public IProcess { pathed_ = true; std::vector path; const bool ok = !goal_.straight && E::FindNavPath(ctx.player, ctx.pos, goal_.targetPos, path); + const bool midWalk = routed_ && !goal_.straight && !ok; if (ok && path.size() >= 2) for (size_t i = 1; i < path.size(); ++i) waypoints_.push_back(path[i]); - else waypoints_.push_back(goal_.targetPos); // the straight line, asked for or the fallback + else if (midWalk) waypoints_.push_back(ctx.pos); // stand: the stuck handler asks again, no beeline + else waypoints_.push_back(goal_.targetPos); // the straight line, asked for or the first route's fallback + routed_ = routed_ || ok; goal_.route.assign(1, ok && !path.empty() ? path.front() : ctx.pos); goal_.route.insert(goal_.route.end(), waypoints_.begin(), waypoints_.end()); // A route that stops short of the goal is PARTIAL (the path query's bounded search on a long walk): // its end is a leg boundary, re-planned from there on arrival rather than by the stuck detector. partial_ = ok && HorizDist(waypoints_.back(), goal_.targetPos) > goal_.reachCm; + legFrom_ = HorizDist(ctx.pos, goal_.targetPos); UE_LOGI("director/Goto: route %zu waypoints (%s), dist=%.0fcm", waypoints_.size(), - ok ? "NavMesh route" : goal_.straight ? "straight line, as asked" : "straight-line fallback", + ok ? "NavMesh route" : goal_.straight ? "straight line, as asked" + : midWalk ? "no route here, left to the stuck handler" : "straight-line fallback", HorizDist(ctx.pos, goal_.targetPos)); if (floorZ_ < 0.f) { // the body's floor limit, read once: what the NavMesh's routes are held to E::WalkLimits w; floorZ_ = E::ReadWalkLimits(ctx.player, &w) ? w.floorZ : 0.f; } const int spot = ok && floorZ_ > 0.f && detours_ < kMaxDetours ? detour_.PassedSpot(goal_.route) : -1; - if (spot >= 0) { // the route climbs a slope this walk already found too steep: round it at once - ++detours_; - detour_.BeginRound(ctx.pos, goal_.targetPos, static_cast(spot), floorZ_); + if (spot >= 0 && detour_.BeginRound(ctx.pos, goal_.targetPos, static_cast(spot), floorZ_)) { + ++detours_; // the route climbs a slope this walk already found too steep: round it at once return ProcStatus::Working; } } @@ -249,9 +256,15 @@ class GotoProcess : public IProcess { while (wp_ + 1 < waypoints_.size() && HorizDist(ctx.pos, waypoints_[wp_]) < kAdvanceCm) ++wp_; const float toWp = HorizDist(ctx.pos, waypoints_[wp_]); if (partial_ && wp_ + 1 == waypoints_.size() && toWp < kAdvanceCm) { + partial_ = false; + if (toPile > legFrom_ - kMinLegCm) { // no nearer: the route's end is no leg boundary, so no re-path loop + UE_LOGW("director/Goto: a partial route ended no nearer the goal (toPile=%.0f) -- left to the stuck " + "handler", toPile); + return ProcStatus::Working; + } ++legs_; UE_LOGI("director/Goto: leg %d reached (toPile=%.0f) -- the route was partial, re-path from here", legs_, toPile); - pathed_ = false; waypoints_.clear(); wp_ = 0; lastWp_ = 0; bestWp_ = 1e9f; bestPile_ = 1e9f; partial_ = false; + pathed_ = false; waypoints_.clear(); wp_ = 0; lastWp_ = 0; bestWp_ = 1e9f; bestPile_ = 1e9f; return ProcStatus::Working; } // Progress = advanced a waypoint OR closer to the current waypoint OR closer to the pile. @@ -262,16 +275,17 @@ class GotoProcess : public IProcess { if (progressed) sinceProgress_ = 0; else if (++sinceProgress_ >= kStuckTicks) { sinceProgress_ = 0; - // FIRST: a CLOSED door? Open it as a player's press would and keep walking through. - if (doorOpens_ < kMaxDoorOpens && TryOpenBlockingDoor(ctx)) { ++doorOpens_; return ProcStatus::Working; } - // THEN: a slope too steep for the body, which the NavMesh routes over (detour.h)? Go round it. + // FIRST: a slope too steep for the body, which the NavMesh routes over (detour.h)? Go round it. + // One trace, so it goes before the door scan; a door is a wall, which this never takes. ue_wrap::FVector steep{}; if (detours_ < kMaxDetours && floorZ_ > 0.f && - detour::SteepAhead(ctx.player, ctx.pos, waypoints_[wp_], floorZ_, &steep)) { + detour::SteepAhead(ctx.player, ctx.pos, waypoints_[wp_], floorZ_, &steep) && + detour_.Begin(ctx.pos, goal_.targetPos, steep, floorZ_)) { ++detours_; - detour_.Begin(ctx.pos, goal_.targetPos, steep, floorZ_); return ProcStatus::Working; } + // THEN: a CLOSED door? Open it as a player's press would and keep walking through. + if (doorOpens_ < kMaxDoorOpens && TryOpenBlockingDoor(ctx)) { ++doorOpens_; return ProcStatus::Working; } // NEVER GIVE UP: grind past physics boxes and clutter. Alternate a sideways JUKE to // slide off the obstacle, and RE-PATH periodically. Goto returns Failed ONLY on a hard // engine problem -- reaching the pile (IsActive->false, Grab takes over) or the run @@ -344,6 +358,8 @@ class GotoProcess : public IProcess { bool pathed_ = false; bool partial_ = false; // the current route stops short of the goal: its end is a leg boundary int legs_ = 0; + float legFrom_ = 0.f; // the walker's distance to the goal where the current route began + bool routed_ = false; // a NavMesh route was had: a later failed query is no reason to beeline std::vector waypoints_; size_t wp_ = 0, lastWp_ = 0; float bestWp_ = 1e9f, bestPile_ = 1e9f; diff --git a/src/votv-coop/src/coop/dev/director/standpoints.cpp b/src/votv-coop/src/coop/dev/director/standpoints.cpp index c905738b6..29700702d 100644 --- a/src/votv-coop/src/coop/dev/director/standpoints.cpp +++ b/src/votv-coop/src/coop/dev/director/standpoints.cpp @@ -8,6 +8,7 @@ #include #include #include +#include namespace coop::director { namespace { @@ -19,39 +20,57 @@ float Flat(const ue_wrap::FVector& a, const ue_wrap::FVector& b) { return std::h constexpr int kMaxLegs = 16; // a walk across the map is a handful of partial routes constexpr float kMinLegProgressCm = 100.f; // a leg that ends no nearer than this has found the edge of the reachable -} // namespace - -bool RouteInLegs(void* player, const ue_wrap::FVector& from, const ue_wrap::FVector& to, float reachCm, - float* outLength) { +// Legs from `from` toward `to` until one ends within `nearCm` of it. On true, `lastFrom` is where that +// leg started, `lenBefore` the flat length walked before it and `lenAll` the whole; each may be null. +bool Legs(void* player, const ue_wrap::FVector& from, const ue_wrap::FVector& to, float nearCm, + ue_wrap::FVector* lastFrom, float* lenBefore, float* lenAll) { ue_wrap::FVector cur = from; float len = 0.f; for (int leg = 0; leg < kMaxLegs; ++leg) { std::vector route; if (!E::FindNavPath(player, cur, to, route) || route.empty()) return false; - for (size_t k = 1; k < route.size(); ++k) len += Flat(route[k - 1], route[k]); + float legLen = 0.f; + for (size_t k = 1; k < route.size(); ++k) legLen += Flat(route[k - 1], route[k]); const ue_wrap::FVector end = route.back(); - if (Flat(end, to) <= reachCm) { - if (outLength) *outLength = len; + if (Flat(end, to) <= nearCm) { + if (lastFrom) *lastFrom = cur; + if (lenBefore) *lenBefore = len; + if (lenAll) *lenAll = len + legLen; return true; } if (Flat(end, to) > Flat(cur, to) - kMinLegProgressCm) return false; // no nearer: unreachable from here + len += legLen; cur = end; } return false; } +} // namespace + +bool RouteInLegs(void* player, const ue_wrap::FVector& from, const ue_wrap::FVector& to, float reachCm, + float* outLength) { + return Legs(player, from, to, reachCm, nullptr, nullptr, outLength); +} + std::vector ReachableStandpoints(void* player, const ue_wrap::FVector& about, float ringCm, int count, float reachCm) { ue_wrap::FVector from; if (!player || count <= 0 || !E::TryGetActorLocation(player, from)) return {}; + // The legs toward the ring's centre are walked once, to the leg whose route reaches the ring: every + // point is asked from that leg's start, as a walker reaches the ring from there whichever point it + // takes, so a far ring costs its legs once and not once per point. + const ue_wrap::FVector centre{about.X, about.Y, from.Z}; // at the height the player stands at + ue_wrap::FVector start{}; + float before = 0.f; + if (!Legs(player, from, centre, ringCm + reachCm, &start, &before, nullptr)) return {}; std::vector> found; for (int i = 0; i < count; ++i) { const float a = 6.2831853f * static_cast(i) / static_cast(count); - ue_wrap::FVector p = from; // at the height the player stands at - p.X = about.X + ringCm * std::cos(a); - p.Y = about.Y + ringCm * std::sin(a); - float len = 0.f; - if (!RouteInLegs(player, from, p, reachCm, &len)) continue; + const ue_wrap::FVector p{about.X + ringCm * std::cos(a), about.Y + ringCm * std::sin(a), from.Z}; + std::vector route; + if (!E::FindNavPath(player, start, p, route) || route.empty() || Flat(route.back(), p) > reachCm) continue; + float len = before; + for (size_t k = 1; k < route.size(); ++k) len += Flat(route[k - 1], route[k]); found.emplace_back(len, p); } std::stable_sort(found.begin(), found.end(), [](const auto& x, const auto& y) { return x.first < y.first; }); diff --git a/src/votv-coop/src/coop/dev/drive_drill_verbs.cpp b/src/votv-coop/src/coop/dev/drive_drill_verbs.cpp index 45d1339bc..776104fb8 100644 --- a/src/votv-coop/src/coop/dev/drive_drill_verbs.cpp +++ b/src/votv-coop/src/coop/dev/drive_drill_verbs.cpp @@ -5,7 +5,7 @@ #include "ue_wrap/actors/inventory.h" #include "ue_wrap/core/call.h" #include "ue_wrap/core/reflection.h" -#include "ue_wrap/engine/engine.h" +#include "ue_wrap/core/types.h" #include "ue_wrap/world/world_singleton.h" #include @@ -13,7 +13,6 @@ namespace coop::dev::drive_drill_verbs { namespace { -namespace E = ue_wrap::engine; namespace R = ue_wrap::reflection; void* PlayerFn(void* player, const wchar_t* name) { diff --git a/src/votv-coop/src/coop/dev/drive_drill_verbs.h b/src/votv-coop/src/coop/dev/drive_drill_verbs.h index 01407c7a6..41b645537 100644 --- a/src/votv-coop/src/coop/dev/drive_drill_verbs.h +++ b/src/votv-coop/src/coop/dev/drive_drill_verbs.h @@ -4,8 +4,6 @@ #pragma once -#include "ue_wrap/core/types.h" - #include namespace coop::dev::drive_drill_verbs { diff --git a/src/votv-coop/src/ue_wrap/core/trace.cpp b/src/votv-coop/src/ue_wrap/core/trace.cpp index 299bb365a..1b70bb1e7 100644 --- a/src/votv-coop/src/ue_wrap/core/trace.cpp +++ b/src/votv-coop/src/ue_wrap/core/trace.cpp @@ -3,12 +3,12 @@ #include "ue_wrap/core/trace.h" #include "ue_wrap/core/call.h" +#include "ue_wrap/core/log.h" #include "ue_wrap/core/reflection.h" #include "ue_wrap/core/reflection_props.h" #include #include -#include namespace ue_wrap::trace { namespace { @@ -53,7 +53,9 @@ bool Resolved() { return g_traceFn && g_kslCdo; } -// FHitResult's members, by the engine struct's own reflection, resolved once. +// FHitResult's members, by the engine struct's own reflection, resolved once. The struct is 0x88 bytes +// in 4.27; the read buffer is on the stack, its size checked against the reflected one. +constexpr int32_t kHitBytes = 256; struct HitLayout { bool tried = false, ok = false; int32_t size = 0, point = -1, normal = -1, actor = -1, component = -1; @@ -70,7 +72,10 @@ const HitLayout& Layout() { l.actor = R::FindPropertyOffset(hr, L"Actor"); l.component = R::FindPropertyOffset(hr, L"Component"); const auto fits = [&](int32_t off, int32_t n) { return off >= 0 && off + n <= l.size; }; - l.ok = fits(l.point, 12) && fits(l.normal, 12) && fits(l.actor, 8) && fits(l.component, 8); + l.ok = l.size <= kHitBytes && fits(l.point, 12) && fits(l.normal, 12) && fits(l.actor, 8) && fits(l.component, 8); + if (!l.ok) + UE_LOGW("trace: FHitResult's members did not resolve (size %d, point %d, normal %d, actor %d, component %d) -- " + "LineHitStatDyn answers unresolved", l.size, l.point, l.normal, l.actor, l.component); return l; } @@ -99,12 +104,12 @@ bool LineHitStatDyn(void* worldCtx, const FVector& start, const FVector& end, Hi *out = Hit{}; out->blocked = f.Get(L"ReturnValue"); if (!out->blocked) return true; - std::vector hit(static_cast(l.size)); - if (!f.GetRaw(L"OutHit", hit.data(), l.size)) return false; - std::memcpy(&out->point, hit.data() + l.point, 12); - std::memcpy(&out->normal, hit.data() + l.normal, 12); - out->actor = Weak(hit.data() + l.actor); - out->component = Weak(hit.data() + l.component); + unsigned char hit[kHitBytes]; + if (!f.GetRaw(L"OutHit", hit, l.size)) return false; + std::memcpy(&out->point, hit + l.point, 12); + std::memcpy(&out->normal, hit + l.normal, 12); + out->actor = Weak(hit + l.actor); + out->component = Weak(hit + l.component); return true; } diff --git a/src/votv-coop/src/ue_wrap/engine/engine_nav.cpp b/src/votv-coop/src/ue_wrap/engine/engine_nav.cpp index 7ecdbe629..9c9ad7240 100644 --- a/src/votv-coop/src/ue_wrap/engine/engine_nav.cpp +++ b/src/votv-coop/src/ue_wrap/engine/engine_nav.cpp @@ -37,8 +37,8 @@ int32_t g_pathPtsOff = -2; // -2 = uncomputed, -1 = not found void EnsureNav() { if (!g_navCdo) g_navCdo = R::FindClassDefaultObject(L"NavigationSystemV1"); - void* cls = R::FindClass(L"NavigationSystemV1"); - if (cls && !g_findPath) g_findPath = R::FindFunction(cls, L"FindPathToLocationSynchronously"); + if (!g_findPath) + if (void* cls = R::FindClass(L"NavigationSystemV1")) g_findPath = R::FindFunction(cls, L"FindPathToLocationSynchronously"); } void EnsurePawn() { @@ -95,7 +95,7 @@ void AddMovementInput(void* pawn, const FVector& worldDir, float scale, bool for namespace { // A float member read by its reflected name on the object's own class. False when it is not there. -// The walk is linear; both readers below are one-shot diagnostics. +// The walk is linear; its readers below run once per walk or once per process. bool ReadFloatMember(void* obj, const wchar_t* name, float* out) { if (!obj) return false; const int32_t off = R::FindPropertyOffset(R::ClassOf(obj), name); From 7b80bdec5be8e72e90afa96b59cae86617577b50 Mon Sep 17 00:00:00 2001 From: pelmentor Date: Sat, 10 Oct 2026 09:41:48 +0600 Subject: [PATCH 061/306] [dev] director: a route from off the NavMesh walks onto it first On a fresh New Game the server drill's walker slid onto ground the NavMesh leaves out, and every path query from where it stood failed: the 48 candidates of the detour search at once, then each re-path. A route now comes from routes::RouteFrom, which projects an off-mesh start onto the NavMesh within 4 m (K2_ProjectPointToNavigation, now in engine_nav) and walks there first; the walk, the legs, the ring and the detour's two queries all ask it. standpoints becomes routes, the name of what the module now holds. Co-Authored-By: Claude Opus 5.5 --- .../dev/director/{standpoints.h => routes.h} | 12 ++++++++-- .../include/ue_wrap/engine/engine_nav.h | 4 ++++ src/votv-coop/sources.cmake | 2 +- .../src/coop/dev/container_view_drill.cpp | 2 +- .../src/coop/dev/desk_ping_drill.cpp | 2 +- .../src/coop/dev/desk_verb_drill.cpp | 2 +- .../src/coop/dev/director/detour.cpp | 9 ++++--- .../src/coop/dev/director/proc_walkgrab.cpp | 3 ++- .../director/{standpoints.cpp => routes.cpp} | 24 +++++++++++++++---- src/votv-coop/src/coop/dev/drive_drill.cpp | 2 +- src/votv-coop/src/coop/dev/fireext_drill.cpp | 2 +- src/votv-coop/src/coop/dev/keypad_drill.cpp | 2 +- src/votv-coop/src/coop/dev/server_drill.cpp | 2 +- .../src/coop/dev/server_upgrade_drill.cpp | 2 +- src/votv-coop/src/coop/dev/subject_drill.cpp | 2 +- .../src/ue_wrap/engine/engine_nav.cpp | 21 ++++++++++++++-- 16 files changed, 69 insertions(+), 24 deletions(-) rename src/votv-coop/include/coop/dev/director/{standpoints.h => routes.h} (64%) rename src/votv-coop/src/coop/dev/director/{standpoints.cpp => routes.cpp} (76%) diff --git a/src/votv-coop/include/coop/dev/director/standpoints.h b/src/votv-coop/include/coop/dev/director/routes.h similarity index 64% rename from src/votv-coop/include/coop/dev/director/standpoints.h rename to src/votv-coop/include/coop/dev/director/routes.h index e6e5ee57f..b7578f11c 100644 --- a/src/votv-coop/include/coop/dev/director/standpoints.h +++ b/src/votv-coop/include/coop/dev/director/routes.h @@ -1,5 +1,6 @@ -// coop/dev/director/standpoints.h -- where a drill's walker stands to act on something no route ends at, as a -// desk's button: the points of a ring about it that a NavMesh route reaches. Dev only (the drills' walks); game thread. +// coop/dev/director/routes.h -- the director's routes over the level's NavMesh: one route from wherever the walker +// stands, reachability asked leg by leg, and the points of a ring about something no route ends at (a desk's button) +// that a route reaches. Every walk and every drill's reachability question goes through here. Dev only; game thread. #pragma once @@ -9,6 +10,13 @@ namespace coop::director { +// A NavMesh route from `from` to `to`, its first point `from`. Where the path query cannot place `from` on the +// NavMesh -- a walker slid onto ground the NavMesh leaves out, where every query from it failed on a fresh New Game -- +// `from` is projected onto it within a few metres (the engine's ProjectPointToNavigation) and the route walks there +// first. False when there is no route even so. +bool RouteFrom(void* player, const ue_wrap::FVector& from, const ue_wrap::FVector& to, + std::vector* route); + // Whether a NavMesh route from `from` reaches `to` (its end within `reachCm`, flat), asked in legs: the engine's path // query spends a bounded search and returns a PARTIAL route on a long walk -- measured on a fresh New Game, whose // players start about 790 m from the base and whose route to it stopped on a 2048-cm tile boundary -- so each leg asks diff --git a/src/votv-coop/include/ue_wrap/engine/engine_nav.h b/src/votv-coop/include/ue_wrap/engine/engine_nav.h index dfbc934ab..b62f5bd30 100644 --- a/src/votv-coop/include/ue_wrap/engine/engine_nav.h +++ b/src/votv-coop/include/ue_wrap/engine/engine_nav.h @@ -16,6 +16,10 @@ namespace ue_wrap::engine { bool FindNavPath(void* worldContext, const FVector& start, const FVector& end, std::vector& outPts); +// The NavMesh point nearest `point` within `extent` (K2_ProjectPointToNavigation on the CDO, the +// default NavMesh, no filter); false when none lies within it. +bool ProjectToNav(void* worldContext, const FVector& point, const FVector& extent, FVector* out); + // APawn::AddMovementInput (resolved on Pawn): accumulates ControlInputVector, which the CMC // consumes each tick, so re-issue it every frame. void AddMovementInput(void* pawn, const FVector& worldDir, float scale, bool force); diff --git a/src/votv-coop/sources.cmake b/src/votv-coop/sources.cmake index 43bd383c2..2a641d806 100644 --- a/src/votv-coop/sources.cmake +++ b/src/votv-coop/sources.cmake @@ -617,7 +617,7 @@ set(VOTVCOOP_SOURCES src/coop/dev/director/container_take_probe.cpp src/coop/dev/director/dup_verifier.cpp src/coop/dev/director/door_approach.cpp - src/coop/dev/director/standpoints.cpp + src/coop/dev/director/routes.cpp src/coop/dev/director/aim_fan.cpp src/coop/dev/director/background_walk.cpp src/coop/dev/director/aimed_grab.cpp diff --git a/src/votv-coop/src/coop/dev/container_view_drill.cpp b/src/votv-coop/src/coop/dev/container_view_drill.cpp index f941656f6..5a75c79bc 100644 --- a/src/votv-coop/src/coop/dev/container_view_drill.cpp +++ b/src/votv-coop/src/coop/dev/container_view_drill.cpp @@ -5,7 +5,7 @@ #include "coop/config/config.h" #include "coop/config/config_registry.h" #include "coop/dev/director/director.h" -#include "coop/dev/director/standpoints.h" +#include "coop/dev/director/routes.h" #include "coop/net/session.h" #include "coop/player/players_registry.h" #include "coop/player/roster.h" diff --git a/src/votv-coop/src/coop/dev/desk_ping_drill.cpp b/src/votv-coop/src/coop/dev/desk_ping_drill.cpp index 60ab0729f..6015ce3b8 100644 --- a/src/votv-coop/src/coop/dev/desk_ping_drill.cpp +++ b/src/votv-coop/src/coop/dev/desk_ping_drill.cpp @@ -5,7 +5,7 @@ #include "coop/config/config.h" #include "coop/config/config_registry.h" #include "coop/dev/director/director.h" -#include "coop/dev/director/standpoints.h" +#include "coop/dev/director/routes.h" #include "coop/dev/game_window.h" #include "coop/interactables/desk_ping_sync.h" // CountsNow: what the lane did on this peer #include "coop/interactables/device_occupancy.h" // LocalHolds: the client entered the coordinates screen diff --git a/src/votv-coop/src/coop/dev/desk_verb_drill.cpp b/src/votv-coop/src/coop/dev/desk_verb_drill.cpp index c512d251a..96fe3f775 100644 --- a/src/votv-coop/src/coop/dev/desk_verb_drill.cpp +++ b/src/votv-coop/src/coop/dev/desk_verb_drill.cpp @@ -7,7 +7,7 @@ #include "coop/config/config_registry.h" #include "coop/dev/desk_verb_drill_internal.h" #include "coop/dev/director/director.h" -#include "coop/dev/director/standpoints.h" +#include "coop/dev/director/routes.h" #include "coop/element/element.h" #include "coop/element/registry.h" #include "coop/interactables/desk_verb_effects.h" // CountsNow: the glosses and sounds sent and made diff --git a/src/votv-coop/src/coop/dev/director/detour.cpp b/src/votv-coop/src/coop/dev/director/detour.cpp index 0c220c493..33d98ae02 100644 --- a/src/votv-coop/src/coop/dev/director/detour.cpp +++ b/src/votv-coop/src/coop/dev/director/detour.cpp @@ -2,9 +2,10 @@ #include "coop/dev/director/detour.h" +#include "coop/dev/director/routes.h" + #include "ue_wrap/core/log.h" #include "ue_wrap/core/trace.h" -#include "ue_wrap/engine/engine_nav.h" #include #include @@ -13,8 +14,6 @@ namespace coop::director::detour { namespace { -namespace E = ue_wrap::engine; - constexpr float kRingsCm[] = {600.f, 1200.f, 2400.f, 4800.f}; // nearest first: the shortest way round constexpr int kRings = sizeof(kRingsCm) / sizeof(kRingsCm[0]); constexpr int kDirections = 12; @@ -169,14 +168,14 @@ bool Search::Holds(void* player, const ue_wrap::FVector& via, std::vector there; - if (!E::FindNavPath(player, from_, via, there) || there.size() < 2 || Flat(there.back(), via) > kViaReachCm) + if (!RouteFrom(player, from_, via, &there) || there.size() < 2 || Flat(there.back(), via) > kViaReachCm) return false; if (PassedSpot(there) >= 0) return false; if (!Walk(there, [&](const ue_wrap::FVector& p, float) { return Standable(player, p); })) return false; // The way on: clear of every remembered spot, its floor sampled for its first stretch. The walker // follows this very route, not a fresh one asked where it stands at the via point. std::vector on; - if (!E::FindNavPath(player, via, goal_, on) || on.size() < 2) return false; + if (!RouteFrom(player, via, goal_, &on) || on.size() < 2) return false; if (PassedSpot(on) >= 0) return false; if (!Walk(on, [&](const ue_wrap::FVector& p, float along) { return along > kOnwardCheckCm || Standable(player, p); })) return false; diff --git a/src/votv-coop/src/coop/dev/director/proc_walkgrab.cpp b/src/votv-coop/src/coop/dev/director/proc_walkgrab.cpp index c6761f206..254b8febb 100644 --- a/src/votv-coop/src/coop/dev/director/proc_walkgrab.cpp +++ b/src/votv-coop/src/coop/dev/director/proc_walkgrab.cpp @@ -16,6 +16,7 @@ #include "coop/dev/director/detour.h" #include "coop/dev/director/director.h" +#include "coop/dev/director/routes.h" #include "ue_wrap/actors/prop.h" #include "ue_wrap/core/call.h" @@ -226,7 +227,7 @@ class GotoProcess : public IProcess { if (!pathed_) { // compute the route (after the hand is clear, so from the real start), again per leg pathed_ = true; std::vector path; - const bool ok = !goal_.straight && E::FindNavPath(ctx.player, ctx.pos, goal_.targetPos, path); + const bool ok = !goal_.straight && RouteFrom(ctx.player, ctx.pos, goal_.targetPos, &path); const bool midWalk = routed_ && !goal_.straight && !ok; if (ok && path.size() >= 2) for (size_t i = 1; i < path.size(); ++i) waypoints_.push_back(path[i]); else if (midWalk) waypoints_.push_back(ctx.pos); // stand: the stuck handler asks again, no beeline diff --git a/src/votv-coop/src/coop/dev/director/standpoints.cpp b/src/votv-coop/src/coop/dev/director/routes.cpp similarity index 76% rename from src/votv-coop/src/coop/dev/director/standpoints.cpp rename to src/votv-coop/src/coop/dev/director/routes.cpp index 29700702d..db46b43d8 100644 --- a/src/votv-coop/src/coop/dev/director/standpoints.cpp +++ b/src/votv-coop/src/coop/dev/director/routes.cpp @@ -1,6 +1,6 @@ -// coop/dev/director/standpoints.cpp -- see coop/dev/director/standpoints.h. +// coop/dev/director/routes.cpp -- see coop/dev/director/routes.h. -#include "coop/dev/director/standpoints.h" +#include "coop/dev/director/routes.h" #include "ue_wrap/engine/engine.h" #include "ue_wrap/engine/engine_nav.h" @@ -18,6 +18,7 @@ namespace E = ue_wrap::engine; float Flat(const ue_wrap::FVector& a, const ue_wrap::FVector& b) { return std::hypot(a.X - b.X, a.Y - b.Y); } constexpr int kMaxLegs = 16; // a walk across the map is a handful of partial routes +constexpr float kProjectCm = 400.f; // RouteFrom looks this far each way for the NavMesh under an off-mesh start constexpr float kMinLegProgressCm = 100.f; // a leg that ends no nearer than this has found the edge of the reachable // Legs from `from` toward `to` until one ends within `nearCm` of it. On true, `lastFrom` is where that @@ -28,7 +29,7 @@ bool Legs(void* player, const ue_wrap::FVector& from, const ue_wrap::FVector& to float len = 0.f; for (int leg = 0; leg < kMaxLegs; ++leg) { std::vector route; - if (!E::FindNavPath(player, cur, to, route) || route.empty()) return false; + if (!RouteFrom(player, cur, to, &route)) return false; float legLen = 0.f; for (size_t k = 1; k < route.size(); ++k) legLen += Flat(route[k - 1], route[k]); const ue_wrap::FVector end = route.back(); @@ -47,6 +48,21 @@ bool Legs(void* player, const ue_wrap::FVector& from, const ue_wrap::FVector& to } // namespace +bool RouteFrom(void* player, const ue_wrap::FVector& from, const ue_wrap::FVector& to, + std::vector* route) { + route->assign(1, from); + std::vector path; + if (!E::FindNavPath(player, from, to, path) || path.empty()) { + ue_wrap::FVector onMesh{}; + if (!E::ProjectToNav(player, from, {kProjectCm, kProjectCm, kProjectCm}, &onMesh) || + !E::FindNavPath(player, onMesh, to, path) || path.empty()) + return false; + route->push_back(onMesh); + } + route->insert(route->end(), path.begin() + 1, path.end()); // the query's own first point is its start + return true; +} + bool RouteInLegs(void* player, const ue_wrap::FVector& from, const ue_wrap::FVector& to, float reachCm, float* outLength) { return Legs(player, from, to, reachCm, nullptr, nullptr, outLength); @@ -68,7 +84,7 @@ std::vector ReachableStandpoints(void* player, const ue_wrap:: const float a = 6.2831853f * static_cast(i) / static_cast(count); const ue_wrap::FVector p{about.X + ringCm * std::cos(a), about.Y + ringCm * std::sin(a), from.Z}; std::vector route; - if (!E::FindNavPath(player, start, p, route) || route.empty() || Flat(route.back(), p) > reachCm) continue; + if (!RouteFrom(player, start, p, &route) || Flat(route.back(), p) > reachCm) continue; float len = before; for (size_t k = 1; k < route.size(); ++k) len += Flat(route[k - 1], route[k]); found.emplace_back(len, p); diff --git a/src/votv-coop/src/coop/dev/drive_drill.cpp b/src/votv-coop/src/coop/dev/drive_drill.cpp index d9c5b9090..152f9f81f 100644 --- a/src/votv-coop/src/coop/dev/drive_drill.cpp +++ b/src/votv-coop/src/coop/dev/drive_drill.cpp @@ -6,7 +6,7 @@ #include "coop/config/config.h" #include "coop/dev/director/director.h" -#include "coop/dev/director/standpoints.h" +#include "coop/dev/director/routes.h" #include "coop/element/registry.h" #include "coop/interactables/drive_payload_sync.h" #include "coop/interactables/drive_sync.h" diff --git a/src/votv-coop/src/coop/dev/fireext_drill.cpp b/src/votv-coop/src/coop/dev/fireext_drill.cpp index af74de857..0510f8a30 100644 --- a/src/votv-coop/src/coop/dev/fireext_drill.cpp +++ b/src/votv-coop/src/coop/dev/fireext_drill.cpp @@ -5,7 +5,7 @@ #include "coop/config/config.h" #include "coop/dev/director/aimed_grab.h" #include "coop/dev/director/director.h" -#include "coop/dev/director/standpoints.h" +#include "coop/dev/director/routes.h" #include "coop/net/session.h" #include "coop/player/local_streams.h" // CurrentHoldGen: the hold the stale tail names #include "coop/player/players_registry.h" diff --git a/src/votv-coop/src/coop/dev/keypad_drill.cpp b/src/votv-coop/src/coop/dev/keypad_drill.cpp index ff937616e..e9d319763 100644 --- a/src/votv-coop/src/coop/dev/keypad_drill.cpp +++ b/src/votv-coop/src/coop/dev/keypad_drill.cpp @@ -4,7 +4,7 @@ #include "coop/config/config.h" #include "coop/dev/director/director.h" -#include "coop/dev/director/standpoints.h" +#include "coop/dev/director/routes.h" #include "coop/interactables/keypad_sync.h" // the keypad lane's key #include "coop/net/session.h" #include "coop/player/players_registry.h" diff --git a/src/votv-coop/src/coop/dev/server_drill.cpp b/src/votv-coop/src/coop/dev/server_drill.cpp index f6dcf94df..472ac4712 100644 --- a/src/votv-coop/src/coop/dev/server_drill.cpp +++ b/src/votv-coop/src/coop/dev/server_drill.cpp @@ -4,7 +4,7 @@ #include "coop/config/config.h" #include "coop/dev/director/director.h" -#include "coop/dev/director/standpoints.h" +#include "coop/dev/director/routes.h" #include "coop/interactables/serverbox_sync.h" #include "coop/net/session.h" #include "coop/player/players_registry.h" diff --git a/src/votv-coop/src/coop/dev/server_upgrade_drill.cpp b/src/votv-coop/src/coop/dev/server_upgrade_drill.cpp index 4bb8f67a9..913b22615 100644 --- a/src/votv-coop/src/coop/dev/server_upgrade_drill.cpp +++ b/src/votv-coop/src/coop/dev/server_upgrade_drill.cpp @@ -5,7 +5,7 @@ #include "coop/config/config.h" #include "coop/dev/director/aim_fan.h" #include "coop/dev/director/director.h" -#include "coop/dev/director/standpoints.h" +#include "coop/dev/director/routes.h" #include "coop/interactables/server_upgrade_sync.h" #include "coop/net/session.h" #include "coop/player/players_registry.h" diff --git a/src/votv-coop/src/coop/dev/subject_drill.cpp b/src/votv-coop/src/coop/dev/subject_drill.cpp index 009a0f822..c04eccc1c 100644 --- a/src/votv-coop/src/coop/dev/subject_drill.cpp +++ b/src/votv-coop/src/coop/dev/subject_drill.cpp @@ -5,7 +5,7 @@ #include "coop/config/config.h" #include "coop/dev/director/aimed_grab.h" #include "coop/dev/director/director.h" -#include "coop/dev/director/standpoints.h" // ReachableStandpoints: the port is off the NavMesh +#include "coop/dev/director/routes.h" // ReachableStandpoints: the port is off the NavMesh #include "coop/element/registry.h" #include "coop/interactables/mirror_slot_entry.h" // Refused: the host's port refused a carried drive #include "coop/net/session.h" diff --git a/src/votv-coop/src/ue_wrap/engine/engine_nav.cpp b/src/votv-coop/src/ue_wrap/engine/engine_nav.cpp index 9c9ad7240..dc4f0ea5a 100644 --- a/src/votv-coop/src/ue_wrap/engine/engine_nav.cpp +++ b/src/votv-coop/src/ue_wrap/engine/engine_nav.cpp @@ -30,6 +30,7 @@ namespace R = reflection; void* g_navCdo = nullptr; // UNavigationSystemV1 CDO (static dispatch target) void* g_findPath = nullptr; // FindPathToLocationSynchronously +void* g_project = nullptr; // K2_ProjectPointToNavigation void* g_pawnClass = nullptr; void* g_addMove = nullptr; // APawn::AddMovementInput void* g_navPathCls = nullptr; @@ -37,8 +38,11 @@ int32_t g_pathPtsOff = -2; // -2 = uncomputed, -1 = not found void EnsureNav() { if (!g_navCdo) g_navCdo = R::FindClassDefaultObject(L"NavigationSystemV1"); - if (!g_findPath) - if (void* cls = R::FindClass(L"NavigationSystemV1")) g_findPath = R::FindFunction(cls, L"FindPathToLocationSynchronously"); + if (g_findPath && g_project) return; + if (void* cls = R::FindClass(L"NavigationSystemV1")) { + if (!g_findPath) g_findPath = R::FindFunction(cls, L"FindPathToLocationSynchronously"); + if (!g_project) g_project = R::FindFunction(cls, L"K2_ProjectPointToNavigation"); + } } void EnsurePawn() { @@ -48,6 +52,19 @@ void EnsurePawn() { } // namespace +bool ProjectToNav(void* worldContext, const FVector& point, const FVector& extent, FVector* out) { + EnsureNav(); + if (!g_navCdo || !g_project || !worldContext || !out) return false; + ParamFrame pf(g_project); + if (!pf.valid()) return false; + pf.Set(L"WorldContextObject", worldContext); + pf.Set(L"Point", point); + pf.Set(L"QueryExtent", extent); // NavData and FilterClass stay null: the default NavMesh, no filter + if (!Call(g_navCdo, pf) || !pf.Get(L"ReturnValue")) return false; + *out = pf.Get(L"ProjectedLocation"); + return true; +} + bool FindNavPath(void* worldContext, const FVector& start, const FVector& end, std::vector& outPts) { outPts.clear(); From b08dc4052e2231ece1f586ecc5982e4d029f8bfc Mon Sep 17 00:00:00 2001 From: pelmentor Date: Sat, 10 Oct 2026 09:53:02 +0600 Subject: [PATCH 062/306] [dev] director: a long walk holds the run key, and a run lets go A fresh New Game puts the players 790 m from the base, and the walk there at walking pace took 218 s of a 300 s budget; a person crossing the map holds the run key. The walker now presses mainPlayer's run input event at the human-input seam while the goal is more than 30 m away (twice the walk speed, the game's own updateSpeed) and lets it go inside 15 m. A run that ends -- reached, failed, deadline or cancelled -- now tells the process that drove its last tick it lost control, on the game thread, so a held key is let go however the walk is over. Co-Authored-By: Claude Opus 5.5 --- .../include/coop/dev/director/director.h | 4 ++ .../src/coop/dev/director/control_manager.cpp | 12 +++++ .../src/coop/dev/director/proc_walkgrab.cpp | 44 +++++++++++++++---- 3 files changed, 52 insertions(+), 8 deletions(-) diff --git a/src/votv-coop/include/coop/dev/director/director.h b/src/votv-coop/include/coop/dev/director/director.h index 6d04913de..1df7382f7 100644 --- a/src/votv-coop/include/coop/dev/director/director.h +++ b/src/votv-coop/include/coop/dev/director/director.h @@ -83,6 +83,8 @@ class IProcess { virtual int Priority() const = 0; virtual bool IsActive(const PlayerContext& ctx) const = 0; virtual ProcStatus OnTick(const PlayerContext& ctx) = 0; // does its work (drives input at the seam) + // Control moved to another process, or the run ended (any way it ends): a key the process holds + // down is let go here. Game thread. virtual void OnLostControl() {} }; @@ -96,6 +98,8 @@ class ControlManager { // moves past the goal's. Returns true iff the goal was grabbed or reached. bool Run(DirectorGoal& goal, int maxSeconds); private: + bool Drive(DirectorGoal& goal, int maxSeconds); // Run's loop; Run then lets the last driver go + std::vector> procs_; IProcess* inControl_ = nullptr; }; diff --git a/src/votv-coop/src/coop/dev/director/control_manager.cpp b/src/votv-coop/src/coop/dev/director/control_manager.cpp index 4e9a47a27..f0a71133e 100644 --- a/src/votv-coop/src/coop/dev/director/control_manager.cpp +++ b/src/votv-coop/src/coop/dev/director/control_manager.cpp @@ -33,6 +33,18 @@ void EndWalks() { g_walkEpoch.fetch_add(1, std::memory_order_acq_rel); } void ControlManager::Add(std::unique_ptr proc) { procs_.push_back(std::move(proc)); } bool ControlManager::Run(DirectorGoal& goal, int maxSeconds) { + const bool done = Drive(goal, maxSeconds); + // However the run ended, the process that drove its last tick loses control: a key it holds is + // let go on the game thread, as a person lets go of it when the walk is over. + if (inControl_) { + IProcess* last = inControl_; + inControl_ = nullptr; + GT::RunAndWait([last](std::atomic& d) { last->OnLostControl(); d.store(1); }); + } + return done; +} + +bool ControlManager::Drive(DirectorGoal& goal, int maxSeconds) { // The deadline is wall time, not a tick count. A tick is a game-thread round trip -- a Post, // then 5 ms polls until the closure has run -- plus the pace below, so it costs about ten // milliseconds and never the four the pace alone suggests. Counting `maxSeconds * 1000 / diff --git a/src/votv-coop/src/coop/dev/director/proc_walkgrab.cpp b/src/votv-coop/src/coop/dev/director/proc_walkgrab.cpp index 254b8febb..8aa749833 100644 --- a/src/votv-coop/src/coop/dev/director/proc_walkgrab.cpp +++ b/src/votv-coop/src/coop/dev/director/proc_walkgrab.cpp @@ -60,6 +60,24 @@ constexpr int kUnstickTicks = 70; // ~0.28 s of sideways juke to slide of constexpr int kSettleTicks = 12; // ~0.25 s braking before the grab constexpr int kMaxDetours = 24; // ways round a slope too steep for the body, per walk (detour.h) constexpr float kMinLegCm = 100.f; // a leg that ends no nearer the goal than this is not a leg: the stuck handler's +// A walk this far from its goal holds the run key (mainPlayer's InpActEvt_run, twice the walk speed), +// as a person crossing the map does, and lets it go this near, so the last metres are walked. +constexpr float kRunFromCm = 3000.f; +constexpr float kRunUntilCm = 1500.f; + +// A key at the human-input seam: mainPlayer's input-action event stub, called with its zeroed frame +// (the Key it is given is not read). False when the stub does not resolve. +bool PressInputEvent(void* player, const wchar_t* stub) { + void* cls = R::FindClass(P::name::MainPlayerClass); + void* fn = cls ? R::FindFunction(cls, stub) : nullptr; + if (!fn || !player) return false; + const int32_t fs = R::FunctionFrameSize(fn); + std::vector f(fs > 0 ? static_cast(fs) : 0, 0u); + R::CallFunction(player, fn, f.empty() ? nullptr : f.data()); + return true; +} +constexpr const wchar_t* kRunPress = L"InpActEvt_run_K2Node_InputActionEvent_8"; // input_run = true +constexpr const wchar_t* kRunRelease = L"InpActEvt_run_K2Node_InputActionEvent_9"; // input_run = false // A walk-to target is reached on its own level only: the walker's centre within this of the target's // height, less than a storey. A route whose end the navmesh put on the floor below counts as // horizontally there, 11 m under a door on the dish building's upper floor. @@ -100,15 +118,9 @@ class ClearHandProcess : public IProcess { ProcStatus OnTick(const PlayerContext& ctx) override { ++ticks_; if (ticks_ == 1) { // input seam ONLY -- the key a human presses to drop (measure it first) - void* cls = R::FindClass(P::name::MainPlayerClass); - void* dropFn = cls ? R::FindFunction(cls, L"InpActEvt_drop_K2Node_InputActionEvent_1") : nullptr; - if (dropFn) { - const int32_t fs = R::FunctionFrameSize(dropFn); - std::vector f(fs > 0 ? static_cast(fs) : 0, 0u); - R::CallFunction(ctx.player, dropFn, f.empty() ? nullptr : f.data()); - } + const bool pressed = PressInputEvent(ctx.player, L"InpActEvt_drop_K2Node_InputActionEvent_1"); UE_LOGI("director/ClearHand: hand full (grabbing=%p holding=%p place=%d) -- input-seam " - "InpActEvt_drop fn=%p; MEASURING", ctx.held, ctx.holding, ctx.placeMode ? 1 : 0, dropFn); + "InpActEvt_drop pressed=%d; MEASURING", ctx.held, ctx.holding, ctx.placeMode ? 1 : 0, pressed ? 1 : 0); return ProcStatus::Working; } if (ticks_ == kDropMeasureTicks && !measured_) { // MEASURE the input-seam result, don't assume @@ -254,6 +266,13 @@ class GotoProcess : public IProcess { } } const float toPile = HorizDist(ctx.pos, goal_.targetPos); + if (!runHeld_ && toPile > kRunFromCm && PressInputEvent(ctx.player, kRunPress)) { + runHeld_ = true; + runPlayer_ = ctx.player; + UE_LOGI("director/Goto: the run key held (toPile=%.0f)", toPile); + } else if (runHeld_ && toPile < kRunUntilCm) { + LetRunGo(); + } while (wp_ + 1 < waypoints_.size() && HorizDist(ctx.pos, waypoints_[wp_]) < kAdvanceCm) ++wp_; const float toWp = HorizDist(ctx.pos, waypoints_[wp_]); if (partial_ && wp_ + 1 == waypoints_.size() && toWp < kAdvanceCm) { @@ -323,7 +342,14 @@ class GotoProcess : public IProcess { UE_LOGI("director/Goto: toPile=%.0f toWp=%.0f wp=%zu/%zu ep=%d", toPile, toWp, wp_, waypoints_.size(), stuckEpisodes_); return ProcStatus::Working; } + void OnLostControl() override { LetRunGo(); } private: + void LetRunGo() { + if (!runHeld_) return; + runHeld_ = false; + if (R::IsLive(runPlayer_)) PressInputEvent(runPlayer_, kRunRelease); + UE_LOGI("director/Goto: the run key let go"); + } // Find the nearest CLOSED door within reach of the stuck bot and press it as a player does: the door's // own press verb on this peer's copy. On a coop client the script gate turns it into a verb the host // runs (coop/interactables/door_verb_intent); as the host or in solo the door's own body decides it here. @@ -361,6 +387,8 @@ class GotoProcess : public IProcess { int legs_ = 0; float legFrom_ = 0.f; // the walker's distance to the goal where the current route began bool routed_ = false; // a NavMesh route was had: a later failed query is no reason to beeline + bool runHeld_ = false; // the run key is down (kRunFromCm), let go near the goal or on losing control + void* runPlayer_ = nullptr; std::vector waypoints_; size_t wp_ = 0, lastWp_ = 0; float bestWp_ = 1e9f, bestPile_ = 1e9f; From 5736bcde05c8ae8299f54c574bbd4c37a790d598 Mon Sep 17 00:00:00 2001 From: pelmentor Date: Sat, 10 Oct 2026 10:02:50 +0600 Subject: [PATCH 063/306] [dev] director: the floor ahead is checked before the walker gets there On a fresh New Game the server drill's walker went down into a gully whose far wall was too steep for the body; from the foot of it every way out climbed such a wall, no via point held, and twice the walker died there and came back at the start. detour::Lookahead keeps the next 30 m of the route followed checked, a few floor samples a tick, and a 2 m run too steep for the body starts the search round it while the walker is still on the rim. Fresh routes and held ways are both checked. Co-Authored-By: Claude Opus 5.5 --- .../include/coop/dev/director/detour.h | 23 ++++++++++++ .../src/coop/dev/director/detour.cpp | 37 +++++++++++++++++++ .../src/coop/dev/director/proc_walkgrab.cpp | 14 +++++++ 3 files changed, 74 insertions(+) diff --git a/src/votv-coop/include/coop/dev/director/detour.h b/src/votv-coop/include/coop/dev/director/detour.h index 1887602c2..4c9372025 100644 --- a/src/votv-coop/include/coop/dev/director/detour.h +++ b/src/votv-coop/include/coop/dev/director/detour.h @@ -27,6 +27,29 @@ bool FloorStandable(void* player, const ue_wrap::FVector& at, float floorZ); bool SteepAhead(void* player, const ue_wrap::FVector& pos, const ue_wrap::FVector& toward, float floorZ, ue_wrap::FVector* spot); +// The floor ahead on the route the walker follows, checked before it gets there: a walker that went down +// into a gully stood at the foot of a wall too steep to climb, every way out of it climbing one, until the +// game killed it (a fresh New Game, twice). Each tick a few floor samples, about a stride apart, keep the +// next stretch of the route checked; a run of samples too steep for the body is the spot to go round, +// found while the walker can still go round it. +class Lookahead { +public: + // A new route to check, from its first point. + void Reset(const std::vector& route); + // Checks up to a few samples, keeping the stretch ahead of `along` (the walker's distance along + // the route) checked. True with `spot` when a run too steep for the body lies ahead of the walker. + bool Advance(void* player, float along, float floorZ, ue_wrap::FVector* spot); + // The walker's distance along the route, given the route point it heads for and its flat distance + // to it. + float Along(size_t toward, float toPoint) const; + +private: + std::vector route_; + std::vector cum_; // the flat length to each route point + float scanned_ = 0.f; // checked up to here + float steepRun_ = 0.f; // the length of the run too steep so far, ending at scanned_ +}; + class Search { public: // Remembers the spot `steep` and starts a search round it, from `from` toward `goal`. False, and no diff --git a/src/votv-coop/src/coop/dev/director/detour.cpp b/src/votv-coop/src/coop/dev/director/detour.cpp index 33d98ae02..46622f92c 100644 --- a/src/votv-coop/src/coop/dev/director/detour.cpp +++ b/src/votv-coop/src/coop/dev/director/detour.cpp @@ -32,6 +32,10 @@ constexpr float kTraceAheadCm = 200.f; // SteepAhead looks this far toward constexpr float kKneeCm = -50.f; // ...at knee height, under the walker's centre constexpr float kWallNormalZ = 0.3f; // a hit flatter than a wall: a slope, not a door, box or fence constexpr size_t kMaxSpots = 32; +constexpr float kLookAheadCm = 3000.f; // the route is kept checked this far ahead of the walker +constexpr int kLookPerTick = 8; // floor samples per tick for it +constexpr float kSteepRunCm = 200.f; // a run this long too steep is a slope, not a bump or an edge +constexpr float kLookMinAheadCm = 300.f; // a spot nearer than this is the stuck handler's (SteepAhead) constexpr float kFailedViaCm = 300.f; // a candidate this near a via point that did not hold is not one float Flat(const ue_wrap::FVector& a, const ue_wrap::FVector& b) { return std::hypot(a.X - b.X, a.Y - b.Y); } @@ -97,6 +101,39 @@ bool SteepAhead(void* player, const ue_wrap::FVector& pos, const ue_wrap::FVecto return true; } +void Lookahead::Reset(const std::vector& route) { + route_ = route; + cum_.assign(route_.size(), 0.f); + for (size_t k = 1; k < route_.size(); ++k) cum_[k] = cum_[k - 1] + Flat(route_[k - 1], route_[k]); + scanned_ = 0.f; + steepRun_ = 0.f; +} + +float Lookahead::Along(size_t toward, float toPoint) const { + if (toward >= cum_.size()) return cum_.empty() ? 0.f : cum_.back(); + return (std::max)(0.f, cum_[toward] - toPoint); +} + +bool Lookahead::Advance(void* player, float along, float floorZ, ue_wrap::FVector* spot) { + if (route_.size() < 2) return false; + size_t seg = 1; + for (int n = 0; n < kLookPerTick && scanned_ < cum_.back() && scanned_ < along + kLookAheadCm; ++n) { + while (seg + 1 < cum_.size() && cum_[seg] < scanned_) ++seg; + const float segLen = cum_[seg] - cum_[seg - 1]; + const float t = segLen > 0.f ? (scanned_ - cum_[seg - 1]) / segLen : 1.f; + const ue_wrap::FVector a = route_[seg - 1], b = route_[seg]; + const ue_wrap::FVector p{a.X + (b.X - a.X) * t, a.Y + (b.Y - a.Y) * t, a.Z + (b.Z - a.Z) * t}; + steepRun_ = FloorStandable(player, p, floorZ) ? 0.f : steepRun_ + kSampleCm; + scanned_ += kSampleCm; + if (steepRun_ >= kSteepRunCm && scanned_ - kSteepRunCm > along + kLookMinAheadCm) { + *spot = p; + steepRun_ = 0.f; + return true; + } + } + return false; +} + bool Search::Begin(const ue_wrap::FVector& from, const ue_wrap::FVector& goal, const ue_wrap::FVector& steep, float floorZ) { size_t at = spots_.size(); diff --git a/src/votv-coop/src/coop/dev/director/proc_walkgrab.cpp b/src/votv-coop/src/coop/dev/director/proc_walkgrab.cpp index 8aa749833..0461afeaf 100644 --- a/src/votv-coop/src/coop/dev/director/proc_walkgrab.cpp +++ b/src/votv-coop/src/coop/dev/director/proc_walkgrab.cpp @@ -229,6 +229,7 @@ class GotoProcess : public IProcess { partial_ = HorizDist(waypoints_.back(), goal_.targetPos) > goal_.reachCm; legFrom_ = HorizDist(ctx.pos, goal_.targetPos); goal_.route = std::move(route); + ahead_.Reset(goal_.route); wp_ = 0; lastWp_ = 0; bestWp_ = 1e9f; bestPile_ = 1e9f; sinceProgress_ = 0; pathed_ = true; } else { @@ -251,6 +252,7 @@ class GotoProcess : public IProcess { // its end is a leg boundary, re-planned from there on arrival rather than by the stuck detector. partial_ = ok && HorizDist(waypoints_.back(), goal_.targetPos) > goal_.reachCm; legFrom_ = HorizDist(ctx.pos, goal_.targetPos); + ahead_.Reset(ok ? goal_.route : std::vector()); UE_LOGI("director/Goto: route %zu waypoints (%s), dist=%.0fcm", waypoints_.size(), ok ? "NavMesh route" : goal_.straight ? "straight line, as asked" : midWalk ? "no route here, left to the stuck handler" : "straight-line fallback", @@ -275,6 +277,17 @@ class GotoProcess : public IProcess { } while (wp_ + 1 < waypoints_.size() && HorizDist(ctx.pos, waypoints_[wp_]) < kAdvanceCm) ++wp_; const float toWp = HorizDist(ctx.pos, waypoints_[wp_]); + // The floor ahead, checked before the walker gets there: a slope too steep for the body found + // while it can still go round, not from the foot of it (detour::Lookahead). + ue_wrap::FVector steepAhead{}; + if (floorZ_ > 0.f && detours_ < kMaxDetours && + ahead_.Advance(ctx.player, ahead_.Along(wp_ + 1, toWp), floorZ_, &steepAhead) && + detour_.Begin(ctx.pos, goal_.targetPos, steepAhead, floorZ_)) { + ++detours_; + UE_LOGI("director/Goto: a slope too steep for the body ahead at (%.0f,%.0f,%.0f) -- going round before it", + steepAhead.X, steepAhead.Y, steepAhead.Z); + return ProcStatus::Working; + } if (partial_ && wp_ + 1 == waypoints_.size() && toWp < kAdvanceCm) { partial_ = false; if (toPile > legFrom_ - kMinLegCm) { // no nearer: the route's end is no leg boundary, so no re-path loop @@ -398,6 +411,7 @@ class GotoProcess : public IProcess { float floorZ_ = -1.f; // the body's lowest floor normal Z; -1 unread, 0 unreadable (no detours) int detours_ = 0; detour::Search detour_; + detour::Lookahead ahead_; // the floor ahead on the route followed }; // ---- GrabProcess ----------------------------------------------------------------------- From d6d19e78cb2843682896ddbce88128c1832add8b Mon Sep 17 00:00:00 2001 From: pelmentor Date: Sat, 10 Oct 2026 10:20:38 +0600 Subject: [PATCH 064/306] [coop] the element registry knows a reused address from its actor The drive drill failed once on a fresh New Game: the world drive the client pocketed was a host mirror, still bound until the host's destroy came back, and the drive taken out of the inventory was born at its address; EidForActor named the old row, the birth was refused as already tracked, and the new drive's row never reached the host. Each actor binding now holds the element's object slot and that slot's serial, allocated as the engine's weak pointer does, and a lookup reads only the object array. The reaper selftest asks registration of the element, since a purged actor's address is now unbound by definition. Co-Authored-By: Claude Opus 5.5 --- src/votv-coop/include/coop/element/registry.h | 24 ++++++--- src/votv-coop/src/coop/element/element.cpp | 4 +- src/votv-coop/src/coop/element/registry.cpp | 49 +++++++++++++++---- .../src/coop/props/prop_element_tracker.cpp | 15 +++--- 4 files changed, 66 insertions(+), 26 deletions(-) diff --git a/src/votv-coop/include/coop/element/registry.h b/src/votv-coop/include/coop/element/registry.h index 63fa6e5de..bf4692cb6 100644 --- a/src/votv-coop/include/coop/element/registry.h +++ b/src/votv-coop/include/coop/element/registry.h @@ -68,14 +68,18 @@ class Registry { // The one actor-to-eid reverse for the whole registry, locals and mirrors alike, maintained by // Element::SetActor and the destructor through NoteActorRebind, so it always matches the live - // binding. O(1); kInvalidId for a null or unbound actor. It does not validate engine liveness: - // a caller holding the pointer across ticks re-validates with reflection::IsLiveByIndex. + // binding. O(1); kInvalidId for a null or unbound actor, and for an address the engine reused: + // a binding holds its actor's object slot and that slot's serial, as the engine's weak pointer + // does, so a new actor at a destroyed one's address is not taken for it (a pocketed mirror, + // still bound until the host's destroy came back, once made the drive taken out of the + // inventory at its address read as already tracked). It does not test the kill flags: a + // destroy seam asks about an actor being destroyed. Object-array reads only, never the actor. ElementId EidForActor(void* actor) const; // Called by Element::SetActor and the destructor: drop the old actor's entry if it still maps - // to `id`, then point the new actor at `id`; the newest live binding wins on a recycled - // address. nullptr clears. Only Element calls it. - void NoteActorRebind(ElementId id, void* oldActor, void* newActor); + // to `id`, then point the new actor, at object slot `newIdx`, at `id`; the newest live binding + // wins on a recycled address. nullptr clears. Only Element calls it. + void NoteActorRebind(ElementId id, void* oldActor, void* newActor, int32_t newIdx); // Range checks; kInvalidId is false on both. static bool IsHostId(ElementId id) { return id < kHostRangeSize; } @@ -129,8 +133,14 @@ class Registry { mutable std::mutex m_mutex; Element* m_byId[kMaxElements] = {}; // index = ElementId; nullptr = free // The actor-to-eid reverse, guarded by m_mutex with m_byId, since they mutate together; locals - // and mirrors. - std::unordered_map m_byActor; + // and mirrors. Each binding holds the actor's object slot and its serial, taken outside the + // lock when the actor is bound (ActorIdent), so a reused address reads as unbound. + struct ActorBinding { + ElementId id; + int32_t idx; + int32_t serial; + }; + std::unordered_map m_byActor; // Free lists as deques: fresh ids pop from the back, and a freed id goes to the front, so it is // re-issued only after the never-allocated pool above it drains, by which time any in-flight // message naming the old id has landed. Both ends O(1). diff --git a/src/votv-coop/src/coop/element/element.cpp b/src/votv-coop/src/coop/element/element.cpp index 4bf620f60..737d18138 100644 --- a/src/votv-coop/src/coop/element/element.cpp +++ b/src/votv-coop/src/coop/element/element.cpp @@ -53,7 +53,7 @@ void Element::SetActor(void* a, int32_t internalIdx) { m_internalIdx = internalIdx; if (m_id != kInvalidId && old != a && !g_registryShuttingDown.load(std::memory_order_acquire)) { - Registry::Get().NoteActorRebind(m_id, old, a); + Registry::Get().NoteActorRebind(m_id, old, a, internalIdx); } } @@ -63,7 +63,7 @@ Element::~Element() { // already been torn down. The OS reclaims memory on process exit. if (g_registryShuttingDown.load(std::memory_order_acquire)) return; // Drop our actor from the unified reverse before releasing the id. - if (m_actor) Registry::Get().NoteActorRebind(m_id, m_actor, nullptr); + if (m_actor) Registry::Get().NoteActorRebind(m_id, m_actor, nullptr, -1); // Mirrors borrowed the id from the host's allocation space; releasing the // id back to the local free stack would corrupt it with foreign entries // over a long session. UnregisterMirror just clears m_byId[id]. diff --git a/src/votv-coop/src/coop/element/registry.cpp b/src/votv-coop/src/coop/element/registry.cpp index 2ec3e3251..e389a658f 100644 --- a/src/votv-coop/src/coop/element/registry.cpp +++ b/src/votv-coop/src/coop/element/registry.cpp @@ -14,6 +14,24 @@ namespace coop::element { namespace { + +// An actor's identity in the object array, taken outside the registry's lock when it is bound: the +// slot the element recorded for it (Element::SetActor) and that slot's serial, allocated as the +// engine's weak pointer allocates it, so a same-address successor never matches. Reads the object +// array only, never the actor: an element may hold a sentinel address (the reaper's selftest). +struct ActorIdent { + void* actor = nullptr; + int32_t idx = -1; + int32_t serial = 0; +}; +ActorIdent IdentOf(void* actor, int32_t idx) { + ActorIdent ai; + if (!actor) return ai; + ai.actor = actor; + ai.idx = idx; + ai.serial = idx >= 0 ? ue_wrap::reflection::AllocateSlotSerial(idx) : 0; + return ai; +} // The peer range is split into one equal band per peer slot. Band 0 is the pre-slot band // used during the boot and seed window before a client knows its slot; bands 1 and up are // the per-client-slot exclusive bands. Slot 0 (the host) reuses band 0 as the pre-slot @@ -101,6 +119,7 @@ void Registry::SetLocalPeerBand(uint8_t slot) { ElementId Registry::AllocHostId(Element* e) { if (!e) return kInvalidId; + const ActorIdent ai = IdentOf(e->GetActor(), e->GetInternalIdx()); std::lock_guard lk(m_mutex); if (m_hostFree.empty()) { UE_LOGW("element::Registry: host range exhausted (32768 active elements); " @@ -122,12 +141,13 @@ ElementId Registry::AllocHostId(Element* e) { } m_byId[id] = e; e->SetId_(id); - if (void* a = e->GetActor()) m_byActor[a] = id; // actor set before id -> reverse here + if (ai.actor) m_byActor[ai.actor] = {id, ai.idx, ai.serial}; // actor set before id -> reverse here return id; } ElementId Registry::AllocLocalId(Element* e) { if (!e) return kInvalidId; + const ActorIdent ai = IdentOf(e->GetActor(), e->GetInternalIdx()); std::lock_guard lk(m_mutex); if (m_localFree.empty()) { UE_LOGW("element::Registry: peer range exhausted (32768 active elements); " @@ -145,7 +165,7 @@ ElementId Registry::AllocLocalId(Element* e) { } m_byId[id] = e; e->SetId_(id); - if (void* a = e->GetActor()) m_byActor[a] = id; // actor set before id -> reverse here + if (ai.actor) m_byActor[ai.actor] = {id, ai.idx, ai.serial}; // actor set before id -> reverse here return id; } @@ -182,6 +202,7 @@ bool Registry::RegisterMirror(ElementId id, Element* e) { UE_LOGW("element::Registry: RegisterMirror(id=%u, nullptr) -- rejecting", id); return false; } + const ActorIdent ai = IdentOf(e->GetActor(), e->GetInternalIdx()); std::lock_guard lk(m_mutex); if (m_byId[id]) { UE_LOGW("element::Registry: RegisterMirror(id=%u) -- slot already populated by existing element (duplicate spawn? wire id collision?)", @@ -191,7 +212,7 @@ bool Registry::RegisterMirror(ElementId id, Element* e) { m_byId[id] = e; e->SetId_(id); e->SetMirror_(true); - if (void* a = e->GetActor()) m_byActor[a] = id; // mirror actor often set before Install -> reverse here + if (ai.actor) m_byActor[ai.actor] = {id, ai.idx, ai.serial}; // mirror actor often set before Install return true; } @@ -219,21 +240,31 @@ Element* Registry::Get(ElementId id) const { ElementId Registry::EidForActor(void* actor) const { if (!actor) return kInvalidId; - std::lock_guard lk(m_mutex); - auto it = m_byActor.find(actor); - return (it == m_byActor.end()) ? kInvalidId : it->second; + ActorBinding b{}; + { + std::lock_guard lk(m_mutex); + auto it = m_byActor.find(actor); + if (it == m_byActor.end()) return kInvalidId; + b = it->second; + } + // The slot still holds this pointer and its serial is the one taken at the bind: the bound actor, + // not a successor at its address. The engine resets a slot's serial once its object is destroyed. + namespace R = ue_wrap::reflection; + if (R::ObjectAt(b.idx) != actor || R::SlotSerial(b.idx) != b.serial) return kInvalidId; + return b.id; } -void Registry::NoteActorRebind(ElementId id, void* oldActor, void* newActor) { +void Registry::NoteActorRebind(ElementId id, void* oldActor, void* newActor, int32_t newIdx) { if (oldActor == newActor) return; + const ActorIdent ai = IdentOf(newActor, newIdx); std::lock_guard lk(m_mutex); if (oldActor) { auto it = m_byActor.find(oldActor); // Only erase if it still points at us: a recycled address already re-pointed to a newer // element must not be clobbered by our teardown. - if (it != m_byActor.end() && it->second == id) m_byActor.erase(it); + if (it != m_byActor.end() && it->second.id == id) m_byActor.erase(it); } - if (newActor) m_byActor[newActor] = id; // newest live binding wins + if (ai.actor) m_byActor[ai.actor] = {id, ai.idx, ai.serial}; // newest live binding wins } size_t Registry::HostCount() const { diff --git a/src/votv-coop/src/coop/props/prop_element_tracker.cpp b/src/votv-coop/src/coop/props/prop_element_tracker.cpp index 29fafe50c..244354971 100644 --- a/src/votv-coop/src/coop/props/prop_element_tracker.cpp +++ b/src/votv-coop/src/coop/props/prop_element_tracker.cpp @@ -500,7 +500,9 @@ size_t ReapDeadLocalPropElements(size_t maxEvictions, bool DebugCheckPropElementReap() { // A sentinel actor address never dereferenced: IsLiveByIndex rejects it on the negative-index - // fast path, the maps key on the pointer value, and ~Prop does not own the actor. + // fast path, the processed-Init set keys on the pointer value, and ~Prop does not own the actor. + // The registry's actor reverse holds a slot and serial, so it never resolves this address: a + // purged actor's address is unbound by definition, and registration is asked of the element. void* deadActor = reinterpret_cast(static_cast(0xDEAD0001)); auto* s = LoadSession(); const bool isHost = (s != nullptr && s->role() == coop::net::Role::Host); @@ -547,9 +549,8 @@ bool DebugCheckPropElementReap() { // AllocAndInstall wrote the reverse entry. } - const bool preRegistered = (coop::element::Registry::Get().Get(deadEid) != nullptr) && - (GetPropElementIdForActor(deadActor) == deadEid) && - HasProcessedInit(deadActor); + const coop::element::Element* deadNow = coop::element::Registry::Get().Get(deadEid); + const bool preRegistered = deadNow && deadNow->GetActor() == deadActor && HasProcessedInit(deadActor); if (!preRegistered) { UE_LOGW("propreap_test: FAIL -- synthetic dead Element not fully registered pre-reap"); return false; @@ -569,10 +570,8 @@ bool DebugCheckPropElementReap() { // The eid is freed only when the parked Element destructs: force the game-thread flush now (we // are on it) and re-check. coop::element::ElementDeleter::Get().Flush(); - // After the flush the dead Element's destructor has run: the eid is freed and the reverse entry - // cleared. - const bool eidFreed = (coop::element::Registry::Get().Get(deadEid) == nullptr) && - (GetPropElementIdForActor(deadActor) == coop::element::kInvalidId); + // After the flush the dead Element's destructor has run: the eid is freed. + const bool eidFreed = coop::element::Registry::Get().Get(deadEid) == nullptr; // Clean up the live control so no synthetic Element stays bound to a random live UObject. if (liveEid != coop::element::kInvalidId) { From cf4ff8ee0f2a3f4efafeca2bab529d193f79cdeb Mon Sep 17 00:00:00 2001 From: pelmentor Date: Sat, 10 Oct 2026 10:24:45 +0600 Subject: [PATCH 065/306] [dev] director: the run key's pawn is held as a weak reference The delta lens on the director's last four commits: the run key was let go through a raw pawn pointer probed with the bare liveness check across ticks, and a revived new pawn was never pressed again; it is now a CachedObjRef, and the input stubs resolve once, their calls checked. The search budget counts every route query and trace, a way to a via point four times its straight distance is refused, and RouteFrom no longer repeats a query from a start the projection hands back. A held way's leg is measured from its via point, a walker with no route asks again every 50 ticks, a ring with an off-mesh centre walks toward its points, and the remaining director and drill route questions go through RouteFrom. Co-Authored-By: Claude Opus 5.5 --- .../include/coop/dev/director/detour.h | 4 +- .../include/coop/dev/director/routes.h | 9 ++- .../dev/director/container_take_probe.cpp | 12 +-- .../src/coop/dev/director/control_manager.cpp | 3 +- .../src/coop/dev/director/detour.cpp | 11 ++- .../src/coop/dev/director/director_run.cpp | 15 ++-- .../src/coop/dev/director/door_approach.cpp | 5 +- .../src/coop/dev/director/proc_walkgrab.cpp | 76 +++++++++++++------ .../src/coop/dev/director/routes.cpp | 31 ++++++-- src/votv-coop/src/coop/dev/fireext_drill.cpp | 3 +- src/votv-coop/src/coop/dev/grid_drill.cpp | 4 +- .../src/coop/dev/inventory_pickup_drill.cpp | 4 +- .../src/ue_wrap/engine/engine_nav.cpp | 1 + 13 files changed, 115 insertions(+), 63 deletions(-) diff --git a/src/votv-coop/include/coop/dev/director/detour.h b/src/votv-coop/include/coop/dev/director/detour.h index 4c9372025..897879fcc 100644 --- a/src/votv-coop/include/coop/dev/director/detour.h +++ b/src/votv-coop/include/coop/dev/director/detour.h @@ -71,6 +71,8 @@ class Search { int PassedSpot(const std::vector& route) const; bool Active() const { return active_; } + // The via point of the last way round found. + const ue_wrap::FVector& LastVia() const { return lastVia_; } size_t Spots() const { return spots_.size(); } private: @@ -89,7 +91,7 @@ class Search { float floorZ_ = 0.f; bool active_ = false; int next_ = 0; // the next candidate: ring * kDirections + direction - int samples_ = 0; // floor samples taken in this Advance + int samples_ = 0; // engine calls (traces, route queries) spent in this Advance float bestScore_ = 0.f; std::vector best_; // the best held route of the ring under evaluation ue_wrap::FVector bestVia_{}; diff --git a/src/votv-coop/include/coop/dev/director/routes.h b/src/votv-coop/include/coop/dev/director/routes.h index b7578f11c..9e4c36ab1 100644 --- a/src/votv-coop/include/coop/dev/director/routes.h +++ b/src/votv-coop/include/coop/dev/director/routes.h @@ -12,8 +12,8 @@ namespace coop::director { // A NavMesh route from `from` to `to`, its first point `from`. Where the path query cannot place `from` on the // NavMesh -- a walker slid onto ground the NavMesh leaves out, where every query from it failed on a fresh New Game -- -// `from` is projected onto it within a few metres (the engine's ProjectPointToNavigation) and the route walks there -// first. False when there is no route even so. +// `from` is projected onto it (the engine's ProjectPointToNavigation, in a 4 m box each way, then a 20 m one) and the +// route walks there first. False when there is no route even so. bool RouteFrom(void* player, const ue_wrap::FVector& from, const ue_wrap::FVector& to, std::vector* route); @@ -28,8 +28,9 @@ bool RouteInLegs(void* player, const ue_wrap::FVector& from, const ue_wrap::FVec float* outLength = nullptr); // The `count` points of a ring of `ringCm` about `about`, at the height `player` stands at, that a route from `player` -// reaches, shortest route first: the legs toward the ring's centre walked once (RouteInLegs' rule), each point asked -// from the start of the leg that reaches the ring. Empty when `player` does not read or no route reaches one. +// reaches, shortest route first: the legs toward the ring's centre walked once (RouteInLegs' rule; toward its points +// when the centre ends no route), each point asked from the start of the leg that reaches the ring. Empty when +// `player` does not read or no route reaches one. std::vector ReachableStandpoints(void* player, const ue_wrap::FVector& about, float ringCm, int count, float reachCm); diff --git a/src/votv-coop/src/coop/dev/director/container_take_probe.cpp b/src/votv-coop/src/coop/dev/director/container_take_probe.cpp index 8f9480460..a1c85e6b8 100644 --- a/src/votv-coop/src/coop/dev/director/container_take_probe.cpp +++ b/src/votv-coop/src/coop/dev/director/container_take_probe.cpp @@ -8,7 +8,8 @@ // only, env-gated (VOTVCOOP_RUN_CTAKE_PROBE=1); greppable "director/ctake: VERDICT". #include "coop/dev/director/director.h" -#include "coop/dev/director/dup_verifier.h" // the no-dup verifier + its positive control +#include "coop/dev/director/dup_verifier.h" +#include "coop/dev/director/routes.h" // the no-dup verifier + its positive control #include "coop/config/config.h" // ReadEnv #include "coop/player/players_registry.h" @@ -278,11 +279,8 @@ void RunContainerTakeProbe() { if (cands.size() > static_cast(kMaxCand)) cands.resize(kMaxCand); float bestLen = 1e30f; for (const Cand& c : cands) { - std::vector path; - if (!E::FindNavPath(rsv->player, at, c.pos, path)) continue; // unreachable - if (HorizDist(path.back(), c.pos) > kReachCm) continue; // route ends short of reach float len = 0.f; - for (size_t i = 1; i < path.size(); ++i) len += HorizDist(path[i - 1], path[i]); + if (!RouteInLegs(rsv->player, at, c.pos, kReachCm, &len)) continue; // no route ends within reach if (len < bestLen) { bestLen = len; pb->container = c.c; pb->targetPos = c.pos; pb->countBefore = c.count; } } if (!pb->container) { @@ -503,9 +501,7 @@ void* PickSharedContainer(void* player, int32_t& outCount, ue_wrap::FVector& out } std::sort(cs.begin(), cs.end(), [](const Cand& a, const Cand& b){ return a.key < b.key; }); // BY CONSTRUCTION for (const Cand& c : cs) { - std::vector path; - if (!E::FindNavPath(player, at, c.p, path)) continue; // feasibility: reachable - if (path.empty() || HorizDist(path.back(), c.p) > kReachCm) continue; + if (!RouteInLegs(player, at, c.p, kReachCm)) continue; // feasibility: reachable outCount = c.cnt; outPos = c.p; outKey = c.key; return c.o; // smallest-key reachable } outCount = 0; outPos = {}; outKey.clear(); diff --git a/src/votv-coop/src/coop/dev/director/control_manager.cpp b/src/votv-coop/src/coop/dev/director/control_manager.cpp index f0a71133e..9b90733cf 100644 --- a/src/votv-coop/src/coop/dev/director/control_manager.cpp +++ b/src/votv-coop/src/coop/dev/director/control_manager.cpp @@ -39,7 +39,8 @@ bool ControlManager::Run(DirectorGoal& goal, int maxSeconds) { if (inControl_) { IProcess* last = inControl_; inControl_ = nullptr; - GT::RunAndWait([last](std::atomic& d) { last->OnLostControl(); d.store(1); }); + if (GT::RunAndWait([last](std::atomic& d) { last->OnLostControl(); d.store(1); }) == GT::kTaskFaulted) + UE_LOGW("director: the run's last driver faulted letting go of control -- a key it held may stay down"); } return done; } diff --git a/src/votv-coop/src/coop/dev/director/detour.cpp b/src/votv-coop/src/coop/dev/director/detour.cpp index 46622f92c..cb3e53350 100644 --- a/src/votv-coop/src/coop/dev/director/detour.cpp +++ b/src/votv-coop/src/coop/dev/director/detour.cpp @@ -14,10 +14,12 @@ namespace coop::director::detour { namespace { -constexpr float kRingsCm[] = {600.f, 1200.f, 2400.f, 4800.f}; // nearest first: the shortest way round +constexpr float kRingsCm[] = {600.f, 1200.f, 2400.f, 4800.f}; // nearest first: a way round near the spot constexpr int kRings = sizeof(kRingsCm) / sizeof(kRingsCm[0]); constexpr int kDirections = 12; -constexpr int kSampleBudget = 120; // floor samples per call: a candidate past it waits for the next tick +constexpr int kSampleBudget = 120; // engine calls per call: a candidate past it waits for the next tick +constexpr float kMaxDetourRatio = 4.f; // a way to the via point this many times its straight distance is no way round +constexpr int kRouteCost = 4; // a route query counts as this many samples constexpr float kMinViaCm = 300.f; // a via point at the walker's feet is no way round constexpr float kSampleCm = 100.f; // floor samples along a route, about a stride apart constexpr float kSpotClearCm = 300.f; // a route keeps this far from a remembered spot @@ -202,16 +204,18 @@ bool Search::Standable(void* player, const ue_wrap::FVector& at) { bool Search::Holds(void* player, const ue_wrap::FVector& via, std::vector* walk, float* score) { if (Flat(via, from_) < kMinViaCm || NearFailedVia(via)) return false; + samples_ += kRouteCost; // The way there: floor the body stands on all along, clear of every remembered spot past the // stretch the walker stands in. std::vector there; if (!RouteFrom(player, from_, via, &there) || there.size() < 2 || Flat(there.back(), via) > kViaReachCm) return false; - if (PassedSpot(there) >= 0) return false; + if (PassedSpot(there) >= 0 || Length(there) > kMaxDetourRatio * Flat(from_, via)) return false; if (!Walk(there, [&](const ue_wrap::FVector& p, float) { return Standable(player, p); })) return false; // The way on: clear of every remembered spot, its floor sampled for its first stretch. The walker // follows this very route, not a fresh one asked where it stands at the via point. std::vector on; + samples_ += kRouteCost; if (!RouteFrom(player, via, goal_, &on) || on.size() < 2) return false; if (PassedSpot(on) >= 0) return false; if (!Walk(on, [&](const ue_wrap::FVector& p, float along) { return along > kOnwardCheckCm || Standable(player, p); })) @@ -233,6 +237,7 @@ Search::Step Search::Advance(void* player, std::vector* route) ue_wrap::FVector floor{}; std::vector walk; float score = 0.f; + ++samples_; if (FloorUnder(player, c, &floor) && Holds(player, {floor.X, floor.Y, floor.Z + kViaLiftCm}, &walk, &score) && (best_.empty() || score < bestScore_)) { diff --git a/src/votv-coop/src/coop/dev/director/director_run.cpp b/src/votv-coop/src/coop/dev/director/director_run.cpp index 7873cd3c6..a79c3a050 100644 --- a/src/votv-coop/src/coop/dev/director/director_run.cpp +++ b/src/votv-coop/src/coop/dev/director/director_run.cpp @@ -5,6 +5,7 @@ // Greppable "director: VERDICT". #include "coop/dev/director/director.h" +#include "coop/dev/director/routes.h" #include "coop/player/players_registry.h" #include "coop/props/prop_element_tracker.h" @@ -76,20 +77,16 @@ bool PickReachablePile(void* player, float minCm, float maxCm, DirectorGoal& goa // its straight distance, while a pile behind a wall winds far around (measured: a // 553cm-straight pile had a 24m NavMesh detour that straight-steer could not traverse). Prefer // the shortest actual walk, and REJECT a pile whose route detours > 3x its straight distance. - float bestLen = 1e30f; int bestPts = -1; + float bestLen = 1e30f; for (const Cand& c : cands) { - std::vector p; - if (!E::FindNavPath(player, at, c.pos, p)) continue; // unreachable // GRAB-REACHABILITY: the route must END within grab distance of the pile. A pile up on a // desk/shelf sits far off the navmesh -- the bot reaches the route-end but never the pile // (measured: a pile 833cm out whose route ended 500cm short -> ungrabbable). - if (HorizDist(p.back(), c.pos) > 160.f) continue; float len = 0.f; - for (size_t i = 1; i < p.size(); ++i) len += HorizDist(p[i - 1], p[i]); + if (!RouteInLegs(player, at, c.pos, 160.f, &len)) continue; // Among grab-reachable piles, prefer the SHORTEST actual walk (doors are fine -- Goto opens // them + re-paths through). - if (len < bestLen) { bestLen = len; bestPts = static_cast(p.size()); - goal.targetActor = c.pile; goal.targetPos = c.pos; } + if (len < bestLen) { bestLen = len; goal.targetActor = c.pile; goal.targetPos = c.pos; } } if (!goal.targetActor) { // no grab-reachable pile -- honest: this save has none in the open UE_LOGW("director: NO grab-reachable chipPile (all sit off the navmesh / behind locked doors) " @@ -99,9 +96,9 @@ bool PickReachablePile(void* player, float minCm, float maxCm, DirectorGoal& goa "to keep away from", refused, awayMinCm); return false; } - UE_LOGI("director: goal pile=%p pos=(%.0f,%.0f,%.0f) straight=%.0fcm routeLen=%.0fcm routePts=%d", + UE_LOGI("director: goal pile=%p pos=(%.0f,%.0f,%.0f) straight=%.0fcm routeLen=%.0fcm", goal.targetActor, goal.targetPos.X, goal.targetPos.Y, goal.targetPos.Z, - HorizDist(goal.targetPos, at), bestLen >= 1e30f ? -1.f : bestLen, bestPts); + HorizDist(goal.targetPos, at), bestLen >= 1e30f ? -1.f : bestLen); if (awayFrom) UE_LOGI("director: the pick kept %.0fcm (flat) from (%.0f,%.0f,%.0f): %d pile(s) in the band refused", awayMinCm, awayFrom->X, awayFrom->Y, awayFrom->Z, refused); diff --git a/src/votv-coop/src/coop/dev/director/door_approach.cpp b/src/votv-coop/src/coop/dev/director/door_approach.cpp index f2746a34e..a6a111617 100644 --- a/src/votv-coop/src/coop/dev/director/door_approach.cpp +++ b/src/votv-coop/src/coop/dev/director/door_approach.cpp @@ -2,8 +2,9 @@ #include "coop/dev/director/door_approach.h" +#include "coop/dev/director/routes.h" + #include "ue_wrap/engine/engine.h" -#include "ue_wrap/engine/engine_nav.h" #include #include @@ -30,7 +31,7 @@ bool PickDoorApproach(void* player, const ue_wrap::FVector& at, void* door, bool const ue_wrap::FVector target{pos.X + fwd.X * kDoorApproachCm * side, pos.Y + fwd.Y * kDoorApproachCm * side, pos.Z}; std::vector path; - if (!E::FindNavPath(player, at, target, path) || path.empty()) { + if (!RouteFrom(player, at, target, &path)) { ++best.noRoute; continue; } diff --git a/src/votv-coop/src/coop/dev/director/proc_walkgrab.cpp b/src/votv-coop/src/coop/dev/director/proc_walkgrab.cpp index 0461afeaf..974fd9f69 100644 --- a/src/votv-coop/src/coop/dev/director/proc_walkgrab.cpp +++ b/src/votv-coop/src/coop/dev/director/proc_walkgrab.cpp @@ -19,6 +19,7 @@ #include "coop/dev/director/routes.h" #include "ue_wrap/actors/prop.h" +#include "ue_wrap/core/cached_obj_ref.h" #include "ue_wrap/core/call.h" #include "ue_wrap/core/log.h" #include "ue_wrap/core/reflection.h" @@ -59,25 +60,37 @@ constexpr int kMaxDoorOpens = 20; // grind: keep opening doors as needed constexpr int kUnstickTicks = 70; // ~0.28 s of sideways juke to slide off a box before re-checking constexpr int kSettleTicks = 12; // ~0.25 s braking before the grab constexpr int kMaxDetours = 24; // ways round a slope too steep for the body, per walk (detour.h) -constexpr float kMinLegCm = 100.f; // a leg that ends no nearer the goal than this is not a leg: the stuck handler's -// A walk this far from its goal holds the run key (mainPlayer's InpActEvt_run, twice the walk speed), -// as a person crossing the map does, and lets it go this near, so the last metres are walked. +constexpr float kMinLegCm = 100.f; // a leg ending no nearer the goal than this is no leg: the stuck handler takes it +constexpr int kRouteRetryTicks = 50; // a walker with no route here asks again this often while it stands +// A walk this far from its goal holds the run key (mainPlayer's InpActEvt_run: at least twice the walk +// speed, by the save's agility, and none on a hungry or tired save -- the game's updateSpeed), as a person +// crossing the map does, and lets it go this near, so the last metres are walked. constexpr float kRunFromCm = 3000.f; constexpr float kRunUntilCm = 1500.f; -// A key at the human-input seam: mainPlayer's input-action event stub, called with its zeroed frame -// (the Key it is given is not read). False when the stub does not resolve. -bool PressInputEvent(void* player, const wchar_t* stub) { - void* cls = R::FindClass(P::name::MainPlayerClass); - void* fn = cls ? R::FindFunction(cls, stub) : nullptr; - if (!fn || !player) return false; - const int32_t fs = R::FunctionFrameSize(fn); +// A key at the human-input seam: mainPlayer's input-action event stub, resolved once by its generated +// name (said once when it does not resolve: a game update renames the K2Node stubs) and called with +// its zeroed frame (the Key it is given is not read). False when it did not resolve or the call failed. +struct InputStub { + const wchar_t* name; + void* fn = nullptr; + bool tried = false; +}; +bool PressInputEvent(void* player, InputStub& stub) { + if (!stub.tried) { + stub.tried = true; + void* cls = R::FindClass(P::name::MainPlayerClass); + stub.fn = cls ? R::FindFunction(cls, stub.name) : nullptr; + if (!stub.fn) UE_LOGW("director: the input stub '%ls' does not resolve -- that key is never pressed", stub.name); + } + if (!stub.fn || !player) return false; + const int32_t fs = R::FunctionFrameSize(stub.fn); std::vector f(fs > 0 ? static_cast(fs) : 0, 0u); - R::CallFunction(player, fn, f.empty() ? nullptr : f.data()); - return true; + return R::CallFunction(player, stub.fn, f.empty() ? nullptr : f.data()); } -constexpr const wchar_t* kRunPress = L"InpActEvt_run_K2Node_InputActionEvent_8"; // input_run = true -constexpr const wchar_t* kRunRelease = L"InpActEvt_run_K2Node_InputActionEvent_9"; // input_run = false +InputStub g_drop{L"InpActEvt_drop_K2Node_InputActionEvent_1"}; +InputStub g_runPress{L"InpActEvt_run_K2Node_InputActionEvent_8"}; // input_run = true +InputStub g_runRelease{L"InpActEvt_run_K2Node_InputActionEvent_9"}; // input_run = false // A walk-to target is reached on its own level only: the walker's centre within this of the target's // height, less than a storey. A route whose end the navmesh put on the floor below counts as // horizontally there, 11 m under a door on the dish building's upper floor. @@ -118,7 +131,7 @@ class ClearHandProcess : public IProcess { ProcStatus OnTick(const PlayerContext& ctx) override { ++ticks_; if (ticks_ == 1) { // input seam ONLY -- the key a human presses to drop (measure it first) - const bool pressed = PressInputEvent(ctx.player, L"InpActEvt_drop_K2Node_InputActionEvent_1"); + const bool pressed = PressInputEvent(ctx.player, g_drop); UE_LOGI("director/ClearHand: hand full (grabbing=%p holding=%p place=%d) -- input-seam " "InpActEvt_drop pressed=%d; MEASURING", ctx.held, ctx.holding, ctx.placeMode ? 1 : 0, pressed ? 1 : 0); return ProcStatus::Working; @@ -146,12 +159,13 @@ class ClearHandProcess : public IProcess { if (ticks_ == kDropMeasureTicks * 2 && ctx.HandFull()) { // still full -> honest failure, not a silent deadline UE_LOGW("director/ClearHand: STILL full after effect-seam clear (grabbing=%p holding=%p place=%d) " "-- no known verb emptied the slot; failing the run", ctx.held, ctx.holding, ctx.placeMode ? 1 : 0); + failed_ = true; return ProcStatus::Failed; } return ProcStatus::Working; // IsActive() flips false once the hand clears -> we yield } void OnLostControl() override { - if (ticks_ > 0) + if (ticks_ > 0 && !failed_) UE_LOGI("director/ClearHand: hand cleared after %d ticks (input-seam-faithful=%d) -- yielding", ticks_, g_clearHandUsedEffectFallback ? 0 : 1); } @@ -159,6 +173,7 @@ class ClearHandProcess : public IProcess { DirectorGoal& goal_; int ticks_ = 0; bool measured_ = false; + bool failed_ = false; // the run ends on this process's failure: the hand was not cleared }; // What stands between a stuck walker and its waypoint, named: a trace at knee and at chest height @@ -219,6 +234,7 @@ class GotoProcess : public IProcess { return onLevel_ ? !Arrived(ctx, goal_) : HorizDist(ctx.pos, goal_.targetPos) > goal_.reachCm; } ProcStatus OnTick(const PlayerContext& ctx) override { + ++tick_; if (detour_.Active()) { // looking for a way round a slope the body cannot climb: stand while it does std::vector route; const detour::Search::Step step = detour_.Advance(ctx.player, &route); @@ -227,7 +243,8 @@ class GotoProcess : public IProcess { // The held way, to the via point and on: a leg like any route, re-pathed at its end when short. waypoints_.assign(route.begin() + 1, route.end()); partial_ = HorizDist(waypoints_.back(), goal_.targetPos) > goal_.reachCm; - legFrom_ = HorizDist(ctx.pos, goal_.targetPos); + // The way round goes sideways first: its onward leg is measured from the via point. + legFrom_ = HorizDist(detour_.LastVia(), goal_.targetPos); goal_.route = std::move(route); ahead_.Reset(goal_.route); wp_ = 0; lastWp_ = 0; bestWp_ = 1e9f; bestPile_ = 1e9f; sinceProgress_ = 0; @@ -237,15 +254,19 @@ class GotoProcess : public IProcess { unstickTicks_ = kUnstickTicks; } } + if (pathed_ && retryRouteAt_ >= 0 && tick_ >= retryRouteAt_) { // standing with no route: ask again + pathed_ = false; waypoints_.clear(); wp_ = 0; lastWp_ = 0; bestWp_ = 1e9f; bestPile_ = 1e9f; + } if (!pathed_) { // compute the route (after the hand is clear, so from the real start), again per leg pathed_ = true; std::vector path; const bool ok = !goal_.straight && RouteFrom(ctx.player, ctx.pos, goal_.targetPos, &path); const bool midWalk = routed_ && !goal_.straight && !ok; if (ok && path.size() >= 2) for (size_t i = 1; i < path.size(); ++i) waypoints_.push_back(path[i]); - else if (midWalk) waypoints_.push_back(ctx.pos); // stand: the stuck handler asks again, no beeline + else if (midWalk) waypoints_.push_back(ctx.pos); // stand and ask again shortly, no beeline else waypoints_.push_back(goal_.targetPos); // the straight line, asked for or the first route's fallback routed_ = routed_ || ok; + retryRouteAt_ = midWalk ? tick_ + kRouteRetryTicks : -1; goal_.route.assign(1, ok && !path.empty() ? path.front() : ctx.pos); goal_.route.insert(goal_.route.end(), waypoints_.begin(), waypoints_.end()); // A route that stops short of the goal is PARTIAL (the path query's bounded search on a long walk): @@ -268,9 +289,10 @@ class GotoProcess : public IProcess { } } const float toPile = HorizDist(ctx.pos, goal_.targetPos); - if (!runHeld_ && toPile > kRunFromCm && PressInputEvent(ctx.player, kRunPress)) { + if (runHeld_ && runPlayer_.Get() != ctx.player) runHeld_ = false; // a new pawn (a revive) holds no key + if (!runHeld_ && toPile > kRunFromCm && PressInputEvent(ctx.player, g_runPress)) { runHeld_ = true; - runPlayer_ = ctx.player; + runPlayer_.Set(ctx.player); UE_LOGI("director/Goto: the run key held (toPile=%.0f)", toPile); } else if (runHeld_ && toPile < kRunUntilCm) { LetRunGo(); @@ -360,8 +382,14 @@ class GotoProcess : public IProcess { void LetRunGo() { if (!runHeld_) return; runHeld_ = false; - if (R::IsLive(runPlayer_)) PressInputEvent(runPlayer_, kRunRelease); - UE_LOGI("director/Goto: the run key let go"); + void* pawn = runPlayer_.Get(); // slot-validated: never the bare liveness probe on a cached pointer + runPlayer_.Reset(); + if (!pawn) { + UE_LOGI("director/Goto: the run key's pawn is gone -- nothing to let go"); + return; + } + if (PressInputEvent(pawn, g_runRelease)) UE_LOGI("director/Goto: the run key let go"); + else UE_LOGW("director/Goto: the run key's release did not run -- the pawn may keep running"); } // Find the nearest CLOSED door within reach of the stuck bot and press it as a player does: the door's // own press verb on this peer's copy. On a coop client the script gate turns it into a verb the host @@ -401,7 +429,9 @@ class GotoProcess : public IProcess { float legFrom_ = 0.f; // the walker's distance to the goal where the current route began bool routed_ = false; // a NavMesh route was had: a later failed query is no reason to beeline bool runHeld_ = false; // the run key is down (kRunFromCm), let go near the goal or on losing control - void* runPlayer_ = nullptr; + ue_wrap::CachedObjRef runPlayer_; // the pawn it was pressed on + int retryRouteAt_ = -1; // standing with no route: the tick to ask again at + int tick_ = 0; // this process's own tick count std::vector waypoints_; size_t wp_ = 0, lastWp_ = 0; float bestWp_ = 1e9f, bestPile_ = 1e9f; diff --git a/src/votv-coop/src/coop/dev/director/routes.cpp b/src/votv-coop/src/coop/dev/director/routes.cpp index db46b43d8..05bd6e8bf 100644 --- a/src/votv-coop/src/coop/dev/director/routes.cpp +++ b/src/votv-coop/src/coop/dev/director/routes.cpp @@ -18,7 +18,9 @@ namespace E = ue_wrap::engine; float Flat(const ue_wrap::FVector& a, const ue_wrap::FVector& b) { return std::hypot(a.X - b.X, a.Y - b.Y); } constexpr int kMaxLegs = 16; // a walk across the map is a handful of partial routes -constexpr float kProjectCm = 400.f; // RouteFrom looks this far each way for the NavMesh under an off-mesh start +// RouteFrom's boxes for the NavMesh about an off-mesh start, each way on all three axes (so a storey within +// one counts): the near one, then a wide one for a walker the game's own physics put far off it. +constexpr float kProjectCm[] = {400.f, 2000.f}; constexpr float kMinLegProgressCm = 100.f; // a leg that ends no nearer than this has found the edge of the reachable // Legs from `from` toward `to` until one ends within `nearCm` of it. On true, `lastFrom` is where that @@ -52,11 +54,20 @@ bool RouteFrom(void* player, const ue_wrap::FVector& from, const ue_wrap::FVecto std::vector* route) { route->assign(1, from); std::vector path; - if (!E::FindNavPath(player, from, to, path) || path.empty()) { + if (!E::FindNavPath(player, from, to, path)) { ue_wrap::FVector onMesh{}; - if (!E::ProjectToNav(player, from, {kProjectCm, kProjectCm, kProjectCm}, &onMesh) || - !E::FindNavPath(player, onMesh, to, path) || path.empty()) - return false; + bool routed = false; + for (const float box : kProjectCm) { + if (!E::ProjectToNav(player, from, {box, box, box}, &onMesh)) continue; + // A start the projection hands back where it was is on the mesh already: the query failed on + // the far end, and asking again from the same point would fail the same way. + if (std::fabs(onMesh.X - from.X) < 1.f && std::fabs(onMesh.Y - from.Y) < 1.f && + std::fabs(onMesh.Z - from.Z) < 1.f) + return false; + routed = E::FindNavPath(player, onMesh, to, path); + break; + } + if (!routed) return false; route->push_back(onMesh); } route->insert(route->end(), path.begin() + 1, path.end()); // the query's own first point is its start @@ -78,7 +89,15 @@ std::vector ReachableStandpoints(void* player, const ue_wrap:: const ue_wrap::FVector centre{about.X, about.Y, from.Z}; // at the height the player stands at ue_wrap::FVector start{}; float before = 0.f; - if (!Legs(player, from, centre, ringCm + reachCm, &start, &before, nullptr)) return {}; + bool legged = Legs(player, from, centre, ringCm + reachCm, &start, &before, nullptr); + // A centre off the NavMesh (a desk's button, a port) may end no route: walk the legs toward the ring's + // points instead, the first that reaches lending its last leg's start to the rest. + for (int i = 0; !legged && i < count; ++i) { + const float a = 6.2831853f * static_cast(i) / static_cast(count); + const ue_wrap::FVector p{about.X + ringCm * std::cos(a), about.Y + ringCm * std::sin(a), from.Z}; + legged = Legs(player, from, p, reachCm, &start, &before, nullptr); + } + if (!legged) return {}; std::vector> found; for (int i = 0; i < count; ++i) { const float a = 6.2831853f * static_cast(i) / static_cast(count); diff --git a/src/votv-coop/src/coop/dev/fireext_drill.cpp b/src/votv-coop/src/coop/dev/fireext_drill.cpp index 0510f8a30..1a793c2d4 100644 --- a/src/votv-coop/src/coop/dev/fireext_drill.cpp +++ b/src/votv-coop/src/coop/dev/fireext_drill.cpp @@ -23,7 +23,6 @@ #include "ue_wrap/core/reflection.h" #include "ue_wrap/engine/engine.h" #include "ue_wrap/engine/engine_attach.h" -#include "ue_wrap/engine/engine_nav.h" #include "ue_wrap/engine/engine_pawn.h" #include @@ -421,7 +420,7 @@ bool PickCarryEnd(void* player, ue_wrap::FVector& out, const char*& why) { const float a = static_cast(k) * 0.785398f; const ue_wrap::FVector want{me.X + kCarryCm * std::cos(a), me.Y + kCarryCm * std::sin(a), me.Z}; std::vector route; - if (!E::FindNavPath(player, me, want, route) || route.size() < 2) continue; + if (!coop::director::RouteFrom(player, me, want, &route) || route.size() < 2) continue; if (Dist(route.back(), g_mountPos) < kCarryCm * 0.6f) continue; out = route.back(); return true; diff --git a/src/votv-coop/src/coop/dev/grid_drill.cpp b/src/votv-coop/src/coop/dev/grid_drill.cpp index a2bb8e109..396113eb2 100644 --- a/src/votv-coop/src/coop/dev/grid_drill.cpp +++ b/src/votv-coop/src/coop/dev/grid_drill.cpp @@ -8,6 +8,7 @@ #include "coop/config/config.h" #include "coop/config/config_registry.h" #include "coop/dev/director/director.h" +#include "coop/dev/director/routes.h" #include "coop/interactables/device_occupancy.h" // the panel's claim #include "coop/net/protocol.h" #include "coop/net/session.h" @@ -26,7 +27,6 @@ #include "ue_wrap/devices/power_control.h" #include "ue_wrap/engine/engine.h" // TryGetActorLocation #include "ue_wrap/engine/engine_component.h" // GetComponentLocation -#include "ue_wrap/engine/engine_nav.h" // FindNavPath #include #include @@ -117,7 +117,7 @@ void SayTarget(void* player, const ue_wrap::FVector& me, const char* what, bool return; } std::vector route; - const bool routed = ue_wrap::engine::FindNavPath(player, me, at, route) && !route.empty(); + const bool routed = coop::director::RouteFrom(player, me, at, &route); const ue_wrap::FVector end = routed ? route.back() : ue_wrap::FVector{}; UE_LOGI("[GRID-DRILL] client census: %s at (%.0f, %.0f, %.0f), %.0f uu away; the route toward it %s " "(%.0f, %.0f, %.0f), %.0f uu short of it", what, at.X, at.Y, at.Z, Dist(me, at), diff --git a/src/votv-coop/src/coop/dev/inventory_pickup_drill.cpp b/src/votv-coop/src/coop/dev/inventory_pickup_drill.cpp index a15b47e56..af4ad448c 100644 --- a/src/votv-coop/src/coop/dev/inventory_pickup_drill.cpp +++ b/src/votv-coop/src/coop/dev/inventory_pickup_drill.cpp @@ -4,6 +4,7 @@ #include "coop/config/config.h" #include "coop/dev/director/director.h" +#include "coop/dev/director/routes.h" #include "record_digest.h" // co-located private header (src tree, not include/) #include "coop/player/players_registry.h" #include "coop/net/session.h" @@ -20,7 +21,6 @@ #include "ue_wrap/core/reflection.h" #include "ue_wrap/desk/drive_chain.h" #include "ue_wrap/engine/engine.h" -#include "ue_wrap/engine/engine_nav.h" #include #include @@ -69,7 +69,7 @@ DWORD WINAPI WalkAwayThread(LPVOID /*arg*/) { const float a = static_cast(k) * 0.785398f; const ue_wrap::FVector want{me.X + 1500.f * std::cos(a), me.Y + 1500.f * std::sin(a), me.Z}; std::vector route; - if (!E::FindNavPath(player, me, want, route) || route.size() < 2) continue; + if (!coop::director::RouteFrom(player, me, want, &route) || route.size() < 2) continue; const ue_wrap::FVector end = route.back(); const float dx = end.X - me.X, dy = end.Y - me.Y; if (dx * dx + dy * dy < 800.f * 800.f) continue; // a route that ends at our feet diff --git a/src/votv-coop/src/ue_wrap/engine/engine_nav.cpp b/src/votv-coop/src/ue_wrap/engine/engine_nav.cpp index dc4f0ea5a..088f375cf 100644 --- a/src/votv-coop/src/ue_wrap/engine/engine_nav.cpp +++ b/src/votv-coop/src/ue_wrap/engine/engine_nav.cpp @@ -88,6 +88,7 @@ bool FindNavPath(void* worldContext, const FVector& start, const FVector& end, void* data = *reinterpret_cast(base); const int32_t num = *reinterpret_cast(base + 8); if (!data || num <= 0 || num > 4096) return false; + outPts.reserve(static_cast(num)); constexpr float kBound = 1.0e7f; for (int32_t i = 0; i < num; ++i) { // TArray stride = sizeof(FVector)=12 (FVector is align-4, not a 16-aligned BP From 9ab019736400c15149bae4971a88941890eef7cc Mon Sep 17 00:00:00 2001 From: pelmentor Date: Sat, 10 Oct 2026 11:16:40 +0600 Subject: [PATCH 066/306] [harness] eventforce waits on readiness, through one shared wait The event-force autotest settled 55 s for the world and the client's transport, paced its snapshots at 2.5 s and gave the forced chain 4 s. WaitPeerWorldReady is a shared readiness wait for the host routines (slot seated and world-ready, the peer's own milestone), and eventforce ends each phase on its own signal: the box resolves, then its shots drop to 0, each within a phase budget. The kick probe's copy of the same wait goes for it. The four other routines that settle on the same 55 s keep their clocks until their bodies are converted. Co-Authored-By: Claude Opus 5.5 --- src/votv-coop/include/harness/autotest.h | 5 ++ src/votv-coop/sources.cmake | 1 + .../harness/autotest/autotest_eventforce.cpp | 68 ++++++++++--------- .../harness/autotest/autotest_kickprobe.cpp | 14 ++-- .../src/harness/autotest/autotest_wait.cpp | 20 ++++++ 5 files changed, 66 insertions(+), 42 deletions(-) create mode 100644 src/votv-coop/src/harness/autotest/autotest_wait.cpp diff --git a/src/votv-coop/include/harness/autotest.h b/src/votv-coop/include/harness/autotest.h index 419fdd255..f0c0e3f4b 100644 --- a/src/votv-coop/include/harness/autotest.h +++ b/src/votv-coop/include/harness/autotest.h @@ -17,6 +17,11 @@ namespace harness::autotest { // since routines poll from worker threads. bool IsClientRole(); +// The host routines' one readiness wait: slot `slot` is seated and its world is ready, the peer's +// own milestone (what its rig: READY peer-world-ready line reports), polled every 50 ms for at most +// `budgetMs`. False when the budget ran out; the caller names its phase. Any thread. +bool WaitPeerWorldReady(int slot, DWORD budgetMs); + // The forced-grab routine (harness/autotest/autotest_grab.cpp): the nearest Aprop_C derivative is // teleported to the player's hand and driven through grabHandle's GrabComponentAtLocation, // SetTargetLocation and ReleaseComponent by reflection, the same ProcessEvent-dispatched diff --git a/src/votv-coop/sources.cmake b/src/votv-coop/sources.cmake index 2a641d806..ff95bdf46 100644 --- a/src/votv-coop/sources.cmake +++ b/src/votv-coop/sources.cmake @@ -755,6 +755,7 @@ set(VOTVCOOP_SOURCES src/harness/autotest/autotest_menuspawn.cpp src/harness/autotest/autotest_config.cpp src/harness/autotest/autotest_dispatch.cpp + src/harness/autotest/autotest_wait.cpp src/harness/autotest/autotest_damage.cpp src/harness/autotest/autotest_dmghazard.cpp src/harness/autotest/autotest_playerdmg.cpp diff --git a/src/votv-coop/src/harness/autotest/autotest_eventforce.cpp b/src/votv-coop/src/harness/autotest/autotest_eventforce.cpp index 914497540..9f6d143b1 100644 --- a/src/votv-coop/src/harness/autotest/autotest_eventforce.cpp +++ b/src/votv-coop/src/harness/autotest/autotest_eventforce.cpp @@ -1,18 +1,13 @@ // harness/autotest/autotest_eventforce.cpp -- event force-NOW smoke driver (coop/dev/event_force). // -// HOST-ONLY (client observes via wire). Verifies the volume-gate feature end to end -// on the canonical row (obelisk): -// 1. PRE -- badge snapshot path: RequestRefresh + StatusFor until the box resolves; a fresh -// save must read armed=0 shots=1 ([volume-gated] badge state). -// 2. FORCE -- ForceNow("obelisk"): HostFire arm (the runEvent watch broadcasts -> the client -// log shows its NOT-replayed line; obelisk's prop spawns and player punch are -// host-only) + the posted overlap dispatch with the local pawn (event_force logs -// "'TB_event_obelisk' FORCED"). -// 3. POST -- snapshot again: shots must have dropped to 0 ([FIRED] badge state), proving the -// native class filter + N decrement + collision-off ran in game bytecode. -// Greppable verdict: "eventforce_test: VERDICT PASS|FAIL". -// -// Gated by env VOTVCOOP_RUN_EVENTFORCE_TEST=1 (autonomous mp.py only; not an ini flag). +// HOST-ONLY (client observes via wire). The volume-gate feature end to end on the canonical row +// (obelisk), each phase ended by readiness, never a clock: slot 1 world-ready (WaitPeerWorldReady), +// so the arm broadcast reaches a live peer; PRE, the badge snapshot until the box resolves (a fresh +// save reads armed=0 shots=1, the [volume-gated] badge); FORCE, ForceNow("obelisk") -- the HostFire +// arm (the client logs its NOT-replayed line; obelisk's prop spawns and punch are host-only) and the +// posted overlap dispatch ("'TB_event_obelisk' FORCED"); POST, snapshots until the shots drop to 0 +// (the [FIRED] badge: the native class filter, N decrement and collision-off ran in game bytecode). +// Greppable verdict: "eventforce_test: VERDICT PASS|FAIL". Env VOTVCOOP_RUN_EVENTFORCE_TEST=1. #include "harness/autotest.h" @@ -29,12 +24,23 @@ namespace { namespace EF = coop::dev::event_force; -// One refresh + settle + read. RequestRefresh is ~1 Hz-limited internally; the -// 2.5 s gap keeps every call effective. -EF::BoxStatus SnapshotOnce(const char* eventName) { - EF::RequestRefresh(); - ::Sleep(2500); - return EF::StatusFor(eventName); +constexpr DWORD kReadyBudgetMs = 180'000; // the client boots, joins, downloads and loads first +constexpr DWORD kPhaseBudgetMs = 60'000; // a snapshot phase: the box resolves, the shots drop +constexpr DWORD kPollMs = 250; + +// Snapshots until `done` holds or the phase's budget runs out. RequestRefresh is about 1 Hz-limited +// internally, so asking each poll costs nothing extra; the refresh lands on the game thread and +// StatusFor reads what it last left. +template +EF::BoxStatus SnapshotUntil(const char* eventName, Done done) { + EF::BoxStatus st{}; + for (DWORD waited = 0; waited < kPhaseBudgetMs; waited += kPollMs) { + EF::RequestRefresh(); + st = EF::StatusFor(eventName); + if (done(st)) return st; + ::Sleep(kPollMs); + } + return st; } } // namespace @@ -44,17 +50,15 @@ void RunAutonomousEventForceTest() { UE_LOGI("eventforce_test: not host -- this routine is host-only (client observes via wire)"); return; } - // Same settle reasoning as eventfire_test: host bind + client transport connect, so the - // arm broadcast has a live peer to reach. - UE_LOGI("eventforce_test: starting on host (waiting 55 s for world + client transport)"); - ::Sleep(55000); - - // PRE: wait for the box snapshot to resolve (world actors up), <= 60 s. - EF::BoxStatus pre; - for (int i = 0; i < 24; ++i) { - pre = SnapshotOnce("obelisk"); - if (pre.resolved) break; + UE_LOGI("eventforce_test: starting on host (waiting for slot 1 to be world-ready)"); + if (!WaitPeerWorldReady(1, kReadyBudgetMs)) { + UE_LOGW("eventforce_test: VERDICT FAIL -- no client was seated and world-ready in slot 1 within %lu s", + static_cast(kReadyBudgetMs / 1000)); + return; } + + // PRE: the box snapshot resolves (world actors up). + const EF::BoxStatus pre = SnapshotUntil("obelisk", [](const EF::BoxStatus& s) { return s.resolved; }); UE_LOGI("eventforce_test: PRE %s resolved=%d armed=%d shots=%d", pre.boxName, pre.resolved ? 1 : 0, pre.armed ? 1 : 0, pre.shots); if (!pre.resolved) { @@ -69,9 +73,9 @@ void RunAutonomousEventForceTest() { UE_LOGW("eventforce_test: VERDICT FAIL -- ForceNow refused (dev gate? row missing?)"); return; } - ::Sleep(4000); // arm + force GT tasks + the native chain - - const EF::BoxStatus post = SnapshotOnce("obelisk"); + // POST: the arm and force game-thread tasks and the native chain have run once the shots drop. + const EF::BoxStatus post = + SnapshotUntil("obelisk", [](const EF::BoxStatus& s) { return s.resolved && s.shots == 0; }); UE_LOGI("eventforce_test: POST %s resolved=%d armed=%d shots=%d", post.boxName, post.resolved ? 1 : 0, post.armed ? 1 : 0, post.shots); diff --git a/src/votv-coop/src/harness/autotest/autotest_kickprobe.cpp b/src/votv-coop/src/harness/autotest/autotest_kickprobe.cpp index 0284c5d8c..886a52463 100644 --- a/src/votv-coop/src/harness/autotest/autotest_kickprobe.cpp +++ b/src/votv-coop/src/harness/autotest/autotest_kickprobe.cpp @@ -16,7 +16,6 @@ namespace harness::autotest { namespace { -constexpr DWORD kPollMs = 500; constexpr DWORD kSeatWaitMs = 180'000; // the client boots, joins, downloads and loads first constexpr DWORD kSettleMs = 10'000; // seated, in the world, and playing before the kick @@ -28,15 +27,10 @@ void RunKickProbe() { return; } auto& s = harness::session_runtime::Session(); - DWORD waited = 0; - while (!(s.IsSlotReady(1) && s.IsSlotWorldReady(1))) { - if (waited >= kSeatWaitMs) { - UE_LOGW("kick_probe: INCONCLUSIVE -- no client was seated and world-ready in slot 1 " - "within %lu s", static_cast(kSeatWaitMs / 1000)); - return; - } - ::Sleep(kPollMs); - waited += kPollMs; + if (!WaitPeerWorldReady(1, kSeatWaitMs)) { + UE_LOGW("kick_probe: INCONCLUSIVE -- no client was seated and world-ready in slot 1 " + "within %lu s", static_cast(kSeatWaitMs / 1000)); + return; } UE_LOGI("kick_probe: slot 1 seated and world-ready -- kicking in %lu s", static_cast(kSettleMs / 1000)); diff --git a/src/votv-coop/src/harness/autotest/autotest_wait.cpp b/src/votv-coop/src/harness/autotest/autotest_wait.cpp new file mode 100644 index 000000000..82eb707ca --- /dev/null +++ b/src/votv-coop/src/harness/autotest/autotest_wait.cpp @@ -0,0 +1,20 @@ +// harness/autotest/autotest_wait.cpp -- see WaitPeerWorldReady in harness/autotest.h. + +#include "harness/autotest.h" + +#include "coop/net/session.h" +#include "harness/session_runtime.h" + +namespace harness::autotest { + +bool WaitPeerWorldReady(int slot, DWORD budgetMs) { + constexpr DWORD kPollMs = 50; + auto& s = harness::session_runtime::Session(); + for (DWORD waited = 0; !(s.IsSlotReady(slot) && s.IsSlotWorldReady(slot)); waited += kPollMs) { + if (waited >= budgetMs) return false; + ::Sleep(kPollMs); + } + return true; +} + +} // namespace harness::autotest From 1250fd713529f729668ca1b060b99d53f6f50d34 Mon Sep 17 00:00:00 2001 From: pelmentor Date: Sat, 10 Oct 2026 11:26:11 +0600 Subject: [PATCH 067/306] [harness] the dmghazard probe judges the refusals, with an ignite arm The probe read a zero health change at the puppet as the game guarding on possession, and it waited on a 180 s clock. It now waits for slot 1 to be world-ready and judges each call by coop::player_damage's refusal counts and the host's health: the damage verb and, new, ignite at the puppet must be refused, addDamage at it must write nothing, and the same verb at the host's own player must land as the control. The flag player_damage_no_refusal turns both refusals off, the probe's red. Co-Authored-By: Claude Opus 5.5 --- .../coop/config/config_registry_rows_dev.inc | 2 + .../include/coop/player/player_damage.h | 7 + src/votv-coop/include/harness/autotest.h | 12 +- .../src/coop/player/player_damage.cpp | 17 +- .../harness/autotest/autotest_dmghazard.cpp | 199 +++++++++++------- 5 files changed, 151 insertions(+), 86 deletions(-) diff --git a/src/votv-coop/include/coop/config/config_registry_rows_dev.inc b/src/votv-coop/include/coop/config/config_registry_rows_dev.inc index 51f2766c8..afdf79c2b 100644 --- a/src/votv-coop/include/coop/config/config_registry_rows_dev.inc +++ b/src/votv-coop/include/coop/config/config_registry_rows_dev.inc @@ -156,6 +156,8 @@ CFG_FLAG(damage_probe, "damage_probe", "dev", false, "VOTVCOOP_DAMAGE_PROBE", "Probe (BOTH peers): every call of the player's damage verb, one [DMG] line each -- this machine's " "own player or a peer's puppet, the amount, the source's class and the calling Blueprint function. " "Read-only.") +CFG_FLAG(player_damage_no_refusal, "player_damage_no_refusal", "dev", false, "VOTVCOOP_PLAYER_DAMAGE_NO_REFUSAL", + "Developer: never refuse damage or ignite on a peer's puppet (the dmghazard probe's red).") CFG_FLAG(event_drill, "event_drill", "dev", false, "VOTVCOOP_EVENT_DRILL", "Drill (BOTH peers): the host fires the meteor shower before the client's world is up (the join " "snapshot carries it) and then through the scheduler itself (the clock set to day 2 00:18), then " diff --git a/src/votv-coop/include/coop/player/player_damage.h b/src/votv-coop/include/coop/player/player_damage.h index 196dadc30..eff152ef3 100644 --- a/src/votv-coop/include/coop/player/player_damage.h +++ b/src/votv-coop/include/coop/player/player_damage.h @@ -10,6 +10,8 @@ #pragma once +#include + namespace coop::net { class Session; struct PlayerDamagePayload; } namespace coop::player_damage { @@ -22,6 +24,11 @@ void Install(coop::net::Session* session); // subsystems::TickGameplay. void Tick(); +// The refusals so far on this machine: the damage verb and ignite refused on a peer's puppet. What +// the dmghazard probe judges by. Any thread. +uint32_t RefusedDamage(); +uint32_t RefusedIgnites(); + // Owner side: validate the payload, verify on the game thread that it addresses this peer // (targetElementId is our Player Element id) and run "Add Player Damage" on our own possessed // mainPlayer_C, so our armour and inventory BP mitigate the hit. The host-only trust gate is diff --git a/src/votv-coop/include/harness/autotest.h b/src/votv-coop/include/harness/autotest.h index f0c0e3f4b..42f4d0818 100644 --- a/src/votv-coop/include/harness/autotest.h +++ b/src/votv-coop/include/harness/autotest.h @@ -154,12 +154,12 @@ DWORD WINAPI PuppetFrameThread(LPVOID arg); void RunAutonomousDamageTest(); DWORD WINAPI DamageTestThread(LPVOID arg); -// The puppet-damage hazard probe (harness/autotest/autotest_dmghazard.cpp), host verdict: Add -// Player Damage is invoked on the unpossessed slot-1 puppet and the host's own saveSlot.health -// diffed; a drop means the saveSlot is shared per machine, so native damage on a puppet must be -// intercepted, not merely relayed. A local-player control separates a BP early-out from a -// non-landing call; health is restored after. The client only connects so the puppet exists. -// Env VOTVCOOP_RUN_DMGHAZARD_TEST=1. +// The puppet-damage hazard probe (harness/autotest/autotest_dmghazard.cpp), host verdict: once +// slot 1 is world-ready, the damage verb, addDamage and ignite are invoked at the slot-1 puppet and +// judged by coop::player_damage's refusal counts against the host's own saveSlot.health (shared per +// machine, so a puppet's damage written here drains the host); the same verb at the host's own player +// is the control that the calls land; health is restored after. PASS = refused; FAIL = landed. Red: +// dev.player_damage_no_refusal. The client only connects. Env VOTVCOOP_RUN_DMGHAZARD_TEST=1. void RunAutonomousDmgHazardTest(); DWORD WINAPI DmgHazardTestThread(LPVOID arg); diff --git a/src/votv-coop/src/coop/player/player_damage.cpp b/src/votv-coop/src/coop/player/player_damage.cpp index 904658d80..ac4239054 100644 --- a/src/votv-coop/src/coop/player/player_damage.cpp +++ b/src/votv-coop/src/coop/player/player_damage.cpp @@ -7,6 +7,8 @@ #include "coop/player/player_damage.h" +#include "coop/config/config.h" +#include "coop/config/config_registry.h" #include "coop/element/element.h" #include "coop/element/player.h" #include "coop/net/protocol.h" @@ -92,7 +94,7 @@ bool OnImpactEntryPre(void* self, void* /*params*/) { constexpr const wchar_t* kDamageVerbName = L"Add Player Damage"; constexpr int kDamageVerbTag = 0x504C4447; // 'PLDG' bool g_damageWatchDone = false; -uint32_t g_verbCanceled = 0; +std::atomic g_verbCanceled{0}; ue_wrap::script_gate::Verdict OnDamageVerbPre(const ue_wrap::script_gate::Call& c) { // IsPuppet alone: no session or local-pawn early-out in front of it, since the local pawn reads @@ -121,7 +123,7 @@ ue_wrap::script_gate::Verdict OnDamageVerbPre(const ue_wrap::script_gate::Call& constexpr const wchar_t* kIgniteName = L"ignite"; constexpr int kIgniteTag = 0x49474E54; // 'IGNT' bool g_igniteWatchDone = false; -uint32_t g_igniteCanceled = 0; +std::atomic g_igniteCanceled{0}; ue_wrap::script_gate::Verdict OnIgnitePre(const ue_wrap::script_gate::Call& c) { // IsPuppet alone: no session or local-pawn early-out in front of it, since the local pawn reads @@ -141,10 +143,21 @@ void Install(coop::net::Session* session) { g_session.store(session, std::memory_order_release); } +uint32_t RefusedDamage() { return g_verbCanceled.load(std::memory_order_relaxed); } +uint32_t RefusedIgnites() { return g_igniteCanceled.load(std::memory_order_relaxed); } + void Tick() { // Publish the local pawn for the interceptor's any-thread compare (GT here; // Registry::Local() is GT-only and cached). g_localPawn.store(coop::players::Registry::Get().Local(), std::memory_order_release); + // The dmghazard probe's red: neither refusal is watched, so a puppet takes damage and fire here. + static const bool s_noRefusal = + coop::config::ResolveFlag(coop::config_registry::rows::player_damage_no_refusal); + if (s_noRefusal && (!g_damageWatchDone || !g_igniteWatchDone)) { + g_damageWatchDone = g_igniteWatchDone = true; + UE_LOGW("player_damage: DRILL -- the damage and ignite refusals on a peer's puppet are OFF " + "(dev.player_damage_no_refusal); a puppet takes them here"); + } if (!g_damageWatchDone) { g_damageWatchDone = true; if (!ue_wrap::script_gate::WatchName(kDamageVerbName, kDamageVerbTag, &OnDamageVerbPre, nullptr)) diff --git a/src/votv-coop/src/harness/autotest/autotest_dmghazard.cpp b/src/votv-coop/src/harness/autotest/autotest_dmghazard.cpp index fa5aebf77..efd89b71e 100644 --- a/src/votv-coop/src/harness/autotest/autotest_dmghazard.cpp +++ b/src/votv-coop/src/harness/autotest/autotest_dmghazard.cpp @@ -1,10 +1,13 @@ -// The puppet-damage hazard probe (VOTVCOOP_RUN_DMGHAZARD_TEST): fire both of mainPlayer_C's damage -// entries at the host's own slot-1 puppet and diff the HOST's saveSlot.health. Interfaces and -// per-routine docs in harness/autotest.h. +// The puppet-damage hazard probe (VOTVCOOP_RUN_DMGHAZARD_TEST): fire mainPlayer_C's two damage +// entries and ignite at the host's own slot-1 puppet and judge by what coop::player_damage refused +// and what the HOST's saveSlot.health did; the same damage on the host's own player is the control +// that the calls land. Each phase ends on readiness, never a clock. Red: +// dev.player_damage_no_refusal. Interfaces and per-routine docs in harness/autotest.h. #include "harness/autotest.h" #include "coop/config/config.h" +#include "coop/player/player_damage.h" #include "coop/player/puppet_drive.h" #include "coop/player/remote_player.h" #include "coop/player/players_registry.h" @@ -12,6 +15,7 @@ #include "ue_wrap/core/game_thread.h" #include "ue_wrap/core/log.h" #include "ue_wrap/core/reflection.h" +#include "ue_wrap/core/reflection_props.h" #include "ue_wrap/actors/vitals.h" #include @@ -120,98 +124,137 @@ void RestoreHostHealth(float v) { WaitDone(done, 8000); } -void ProbeDamageHazardOnHost() { - UE_LOGI("dmghazard[host]: probe armed -- fire BOTH damage entries (Add Player Damage + " - "addDamage) on the slot-1 puppet, diff host saveSlot.health"); +// Invoke AmainPlayer_C::ignite(fuel) on `target`. Game-thread only. +bool InvokeIgnite(void* target, float fuel) { + if (!target || !R::IsLive(target)) return false; + void* cls = R::FindClass(L"mainPlayer_C"); + void* fn = cls ? R::FindFunction(cls, L"ignite") : nullptr; + if (!fn) { UE_LOGW("dmghazard: 'ignite' UFunction did not resolve"); return false; } + ue_wrap::ParamFrame f(fn); + if (!f.valid()) return false; + f.Set(L"fuel", fuel); + return ue_wrap::Call(target, f); +} - // Wait for BOTH the slot-1 puppet (client connected) AND the host saveSlot. +// The puppet's own isBurning, by its reflected bool storage. -1 unread. Game-thread only. +int ReadBurningGT(void* actor) { + auto done = std::make_shared>(0); + auto out = std::make_shared(-1); + GT::Post([actor, out, done] { + int32_t off = -1; + uint8_t mask = 0; + if (actor && R::IsLive(actor) && R::FindBoolProperty(R::ClassOf(actor), L"isBurning", off, mask)) + *out = (static_cast(actor)[off] & mask) ? 1 : 0; + done->store(1); + }); + WaitDone(done, 8000); + return *out; +} + +// What one call at a body came to: the refusal counted, the host's health dropped, or neither within +// the phase -- read every 50 ms until one shows, since a refusal or a health write lands within the +// call's own game-thread task or the next frame. +enum class Outcome { Refused, Landed, Nothing }; +const char* Name(Outcome o) { return o == Outcome::Refused ? "REFUSED" : o == Outcome::Landed ? "LANDED" : "NOTHING"; } +constexpr DWORD kPhaseMs = 3000; +constexpr DWORD kPollMs = 50; + +template +Outcome Await(Refused refused, Landed landed) { + for (DWORD waited = 0; waited < kPhaseMs; waited += kPollMs) { + if (refused()) return Outcome::Refused; + if (landed()) return Outcome::Landed; + ::Sleep(kPollMs); + } + return refused() ? Outcome::Refused : landed() ? Outcome::Landed : Outcome::Nothing; +} + +void ProbeDamageHazardOnHost() { + namespace PD = coop::player_damage; + UE_LOGI("dmghazard[host]: probe armed -- the damage verb, addDamage and ignite at the slot-1 puppet, " + "judged by the refusals and the host's own saveSlot.health"); + constexpr DWORD kReadyMs = 180'000; // the client boots, joins, downloads and loads first + if (!WaitPeerWorldReady(1, kReadyMs)) { + UE_LOGW("dmghazard[host]: VERDICT INCONCLUSIVE -- no client was seated and world-ready in slot 1"); + UE_LOGI("dmghazard[host]: DONE"); + return; + } + // The puppet is the host's own milestone after the slot's world-ready: it spawns once the peer's + // pose arrives. Read every 50 ms until it and this machine's player resolve, within a phase. auto puppet = std::make_shared(nullptr); - auto before = std::make_shared(-1.f); - // 180 s: the window has to outlast a cold client boot and connect that only starts after the - // host's own boot armed this probe. - for (int attempt = 0; attempt < 180 && (!*puppet || *before < 0.f); ++attempt) { + auto local = std::make_shared(nullptr); + constexpr DWORD kPuppetMs = 30'000; + for (DWORD waited = 0; (!*puppet || !*local) && waited < kPuppetMs; waited += kPollMs) { auto done = std::make_shared>(0); - GT::Post([puppet, before, done] { + GT::Post([puppet, local, done] { void* p = coop::puppet_drive::Puppet(1).GetActor(); if (p && R::IsLive(p)) *puppet = p; - float v = -1.f; if (ue_wrap::vitals::Read(ue_wrap::vitals::Field::Health, &v)) *before = v; + void* mp = coop::players::Registry::Get().Local(); + if (mp && R::IsLive(mp)) *local = mp; done->store(1); }); WaitDone(done, 8000); - if (!*puppet || *before < 0.f) ::Sleep(1000); + if (!*puppet || !*local) ::Sleep(kPollMs); } - if (!*puppet) { UE_LOGW("dmghazard[host]: VERDICT INCONCLUSIVE -- slot-1 puppet never resolved (no client connected?)"); UE_LOGI("dmghazard[host]: DONE"); return; } - if (*before < 0.f) { UE_LOGW("dmghazard[host]: VERDICT INCONCLUSIVE -- host saveSlot.health never resolved"); UE_LOGI("dmghazard[host]: DONE"); return; } - UE_LOGI("dmghazard[host]: slot-1 puppet resolved; host saveSlot.health BEFORE=%.2f", *before); + const float before = ReadHostHealthGT(); + if (!*puppet || !*local || before < 0.f) { + UE_LOGW("dmghazard[host]: VERDICT INCONCLUSIVE -- puppet=%p local=%p health=%.2f unread after world-ready", + *puppet, *local, before); + UE_LOGI("dmghazard[host]: DONE"); + return; + } + const auto healthBelow = [](float h) { return [h] { const float n = ReadHostHealthGT(); return n >= 0.f && n < h - 0.01f; }; }; - // --- Test 1: high-level entry "Add Player Damage" on the PUPPET --- - const float b1 = ReadHostHealthGT(); + // 1. The damage verb at the puppet: refused, never written to this machine's health. + const uint32_t d1 = PD::RefusedDamage(); + const float h1 = ReadHostHealthGT(); InvokeAddPlayerDamageGT(*puppet, 5.f, "PUPPET"); - ::Sleep(300); - const float a1 = ReadHostHealthGT(); - const float d1 = (b1 >= 0.f && a1 >= 0.f) ? (b1 - a1) : 0.f; - UE_LOGI("dmghazard[host]: 'Add Player Damage' on puppet: host health %.2f -> %.2f (delta=%.2f)", b1, a1, d1); + const Outcome o1 = Await([d1] { return PD::RefusedDamage() > d1; }, healthBelow(h1)); + UE_LOGI("dmghazard[host]: 'Add Player Damage' at the puppet -> %s (health %.2f -> %.2f)", Name(o1), h1, + ReadHostHealthGT()); - // --- Test 2: hit-actor entry "addDamage" on the PUPPET (the native-enemy-shaped call) --- - const float b2 = ReadHostHealthGT(); + // 2. addDamage at the puppet, the entry a native hit forwards to: its nested verb is refused. + const uint32_t d2 = PD::RefusedDamage(); + const float h2 = ReadHostHealthGT(); InvokeAddDamageGT(*puppet, *puppet, 5.f, "PUPPET"); - ::Sleep(300); - const float a2 = ReadHostHealthGT(); - const float d2 = (b2 >= 0.f && a2 >= 0.f) ? (b2 - a2) : 0.f; - UE_LOGI("dmghazard[host]: 'addDamage' on puppet: host health %.2f -> %.2f (delta=%.2f)", b2, a2, d2); - - if (d1 > 0.01f || d2 > 0.01f) { - RestoreHostHealth(*before); - UE_LOGW("dmghazard[host]: VERDICT = SHARED-SAVESLOT CORRUPTION CONFIRMED -- invoking %s on a " - "host-side UNPOSSESSED puppet drained the HOST'S OWN saveSlot.health (Add Player Damage " - "delta=%.2f, addDamage delta=%.2f). Health is the per-machine shared saveSlot, so " - "native damage on a puppet has to be intercepted and routed to the owner as a " - "reliable PlayerDamage event, never run against the host's own store " - "(host health restored to %.2f).", - (d1 > 0.01f ? "Add Player Damage" : "addDamage"), d1, d2, *before); - UE_LOGI("dmghazard[host]: DONE"); - return; + const Outcome o2 = Await([d2] { return PD::RefusedDamage() > d2; }, healthBelow(h2)); + UE_LOGI("dmghazard[host]: 'addDamage' at the puppet -> %s (health %.2f -> %.2f)", Name(o2), h2, + ReadHostHealthGT()); + + // 3. ignite at the puppet: refused, the puppet never burning here. + const uint32_t i3 = PD::RefusedIgnites(); + { + auto d = std::make_shared>(0); + void* target = *puppet; + GT::Post([target, d] { InvokeIgnite(target, 10.f); d->store(1); }); + WaitDone(d, 8000); } + void* pup = *puppet; + const Outcome o3 = Await([i3] { return PD::RefusedIgnites() > i3; }, [pup] { return ReadBurningGT(pup) == 1; }); + UE_LOGI("dmghazard[host]: 'ignite' at the puppet -> %s (puppet isBurning=%d)", Name(o3), ReadBurningGT(pup)); - // Neither puppet entry dropped host health -> control: the SAME Add Player Damage on the host's - // OWN possessed player. That separates "both entries early-out on the unpossessed puppet" (the - // writes work, guarded by possession, so they are safe) from "the calls never landed" (a param - // or resolution bug, which makes the verdict inconclusive). - UE_LOGI("dmghazard[host]: neither puppet entry changed host health -- running a LOCAL control " - "(Add Player Damage on the host's OWN player) to tell early-out from a non-landing call"); - auto local = std::make_shared(nullptr); - const float bc = [&] { - auto done = std::make_shared>(0); - auto h = std::make_shared(-1.f); - GT::Post([local, h, done] { - void* mp = coop::players::Registry::Get().Local(); - if (mp && R::IsLive(mp)) *local = mp; - float v = -1.f; if (ue_wrap::vitals::Read(ue_wrap::vitals::Field::Health, &v)) *h = v; - done->store(1); - }); - WaitDone(done, 8000); - return *h; - }(); - if (!*local || bc < 0.f) { UE_LOGW("dmghazard[host]: VERDICT INCONCLUSIVE -- both puppet calls were no-ops AND no local player/health for the control"); UE_LOGI("dmghazard[host]: DONE"); return; } + // 4. The control: the same damage verb at the host's own player lands, so a REFUSED above is the + // refusal and not a call that never reached the verb. + const float h4 = ReadHostHealthGT(); InvokeAddPlayerDamageGT(*local, 5.f, "LOCAL player"); - ::Sleep(300); - const float ac = ReadHostHealthGT(); - const float dc = (bc >= 0.f && ac >= 0.f) ? (bc - ac) : 0.f; - UE_LOGI("dmghazard[host]: LOCAL control: host saveSlot.health %.2f -> %.2f (delta=%.2f)", bc, ac, dc); - if (dc > 0.01f) { - RestoreHostHealth(*before); - UE_LOGW("dmghazard[host]: VERDICT = PUPPET-ENTRIES-EARLY-OUT -- BOTH 'Add Player Damage' AND " - "'addDamage' are no-ops on the unpossessed puppet (zero host-health change), while the SAME " - "'Add Player Damage' on the host's OWN possessed player dropped %.2f -> the damage path " - "writes the shared saveSlot but GUARDS on possession (GetController()). So directly invoking " - "either entry on a puppet is SAFE. RESIDUAL: the native enemy attack reaches the pawn via " - "impactDamageCPP/overlap (a native hit-actor forward) -- if that bypasses the possession " - "guard it could still corrupt, which is why those entries are intercepted on puppets. " - "Host health restored to %.2f.", dc, *before); + const Outcome o4 = Await([] { return false; }, healthBelow(h4)); + UE_LOGI("dmghazard[host]: control 'Add Player Damage' at this machine's player -> %s (health %.2f -> %.2f)", + Name(o4), h4, ReadHostHealthGT()); + RestoreHostHealth(before); + + const bool refusedAll = o1 == Outcome::Refused && o2 != Outcome::Landed && o3 == Outcome::Refused; + const bool controlLanded = o4 == Outcome::Landed; + if (o1 == Outcome::Landed || o2 == Outcome::Landed || o3 == Outcome::Landed) { + UE_LOGW("dmghazard[host]: VERDICT FAIL -- a peer's puppet took %s%s%s on this machine (health restored to " + "%.2f)", o1 == Outcome::Landed ? "the damage verb " : "", o2 == Outcome::Landed ? "addDamage " : "", + o3 == Outcome::Landed ? "fire" : "", before); + } else if (refusedAll && controlLanded) { + UE_LOGI("dmghazard[host]: VERDICT PASS -- the damage verb and ignite at a peer's puppet were refused, " + "addDamage wrote nothing, and the same verb at this machine's player landed (health restored to " + "%.2f)", before); } else { - UE_LOGW("dmghazard[host]: VERDICT INCONCLUSIVE -- no entry (puppet OR local control) changed host " - "health; the damage calls aren't landing (param/resolution). Revise the probe / verify the " - "UFunction names."); + UE_LOGW("dmghazard[host]: VERDICT INCONCLUSIVE -- verb=%s addDamage=%s ignite=%s control=%s: a call that " + "neither was refused nor landed did not reach its verb", Name(o1), Name(o2), Name(o3), Name(o4)); } UE_LOGI("dmghazard[host]: DONE"); } From 9b31a9e580a1a91de7044c53a6101f6e2db292a4 Mon Sep 17 00:00:00 2001 From: pelmentor Date: Sat, 10 Oct 2026 11:44:12 +0600 Subject: [PATCH 068/306] [dev] a sack drill proves a client's point sack is paid once The point sack lane (kind 173) had no drill: its dispatch row stood on reading alone. sack_drill=run has the host spawn a prop_pointSack_C a metre before the client's puppet once its world is ready; the client waits for the mirror to lie still, turns its own trace onto it with the aim fan, waits for the player's look to take the sack (a press on the tick the trace lands is denied; MainPlayerHasSelectedAction) and presses, and names its look state if the look never settles. The host's DONE needs one paid redemption and its balance up by the sack's points. point_sack_client_runs lets the client's body run instead, the red. Co-Authored-By: Claude Opus 5.5 --- .../coop/config/config_registry_rows_dev.inc | 5 + src/votv-coop/include/coop/dev/sack_drill.h | 23 ++ .../include/coop/items/point_sack_intent.h | 3 + .../ue_wrap/engine/engine_mainplayer.h | 5 + src/votv-coop/sources.cmake | 1 + src/votv-coop/src/coop/dev/dev_lanes.cpp | 5 +- src/votv-coop/src/coop/dev/sack_drill.cpp | 254 ++++++++++++++++++ .../src/coop/items/point_sack_intent.cpp | 9 + .../src/ue_wrap/engine/engine_mainplayer.cpp | 8 + 9 files changed, 312 insertions(+), 1 deletion(-) create mode 100644 src/votv-coop/include/coop/dev/sack_drill.h create mode 100644 src/votv-coop/src/coop/dev/sack_drill.cpp diff --git a/src/votv-coop/include/coop/config/config_registry_rows_dev.inc b/src/votv-coop/include/coop/config/config_registry_rows_dev.inc index afdf79c2b..a9bd994c8 100644 --- a/src/votv-coop/include/coop/config/config_registry_rows_dev.inc +++ b/src/votv-coop/include/coop/config/config_registry_rows_dev.inc @@ -469,6 +469,11 @@ CFG_FLAG(physmods_drill, "physmods_drill", "dev", false, "VOTVCOOP_PHYSMODS_DRIL "line says whether its array holds that one module alone. A joiner whose desk already holds modules at " "its world-ready (a rejoin into the drill's world) plugs nothing and says what it loaded. Lines tagged " "[PHYSMODS-DRILL].") +CFG_STRING(sack_drill, "sack_drill", "dev", "", "VOTVCOOP_SACK_DRILL", false, + "Drill (BOTH peers): a client's point sack is paid by the host, once. run -- the host spawns a sack before the " + "client's puppet once its world is ready; the client aims and presses it; the host's DONE line ends it.") +CFG_FLAG(point_sack_client_runs, "point_sack_client_runs", "dev", false, "VOTVCOOP_POINT_SACK_CLIENT_RUNS", + "Developer: a client's point sack runs the game's own body there instead of asking the host (the sack drill's red).") CFG_FLAG(drone_call_drill, "drone_call_drill", "dev", false, "VOTVCOOP_DRONE_CALL_DRILL", "Drill (BOTH peers): the client walks to the garage's drone console, opens its lid if it is shut, aims " "at its keyboard and presses it through the player's own E dispatch; the press goes to the host, which " diff --git a/src/votv-coop/include/coop/dev/sack_drill.h b/src/votv-coop/include/coop/dev/sack_drill.h new file mode 100644 index 000000000..a86c0dc40 --- /dev/null +++ b/src/votv-coop/include/coop/dev/sack_drill.h @@ -0,0 +1,23 @@ +// coop/dev/sack_drill.h -- [dev] a client's point sack is paid by the host, once. +// HOST -- once slot 1's world is ready and its puppet stands, spawns a prop_pointSack_C a metre in front of the +// puppet and reads its own balance and the sack's points; DONE when its balance rose by exactly those +// points with one paid redemption, FAIL when no pay came within 60 s or the balance moved by another sum. +// CLIENT -- once its world is ready, finds the sack's mirror, turns its own interaction trace onto it with the aim +// fan, presses useSelectedAction (the player's E) and says when its mirrored balance rose. +// The red: point_sack_client_runs=1 on the client lets the client's own body run, so the host is never paid and its +// FAIL ends the run. "[SACK-DRILL] FAIL" is the lane failing (--fail-marker), "[SACK-DRILL] ABANDONED" the drill unable +// to do its part (--dead-marker). Run with sack_drill=run and --done-marker "[SACK-DRILL] host DONE". + +#pragma once + +namespace coop::net { class Session; } + +namespace coop::dev::sack_drill { + +// Advances this peer's steps; a latched read when off. Game thread. +void Tick(coop::net::Session* s); + +// The session ended: the drill starts over. +void OnDisconnect(); + +} // namespace coop::dev::sack_drill diff --git a/src/votv-coop/include/coop/items/point_sack_intent.h b/src/votv-coop/include/coop/items/point_sack_intent.h index f90833a74..119d34849 100644 --- a/src/votv-coop/include/coop/items/point_sack_intent.h +++ b/src/votv-coop/include/coop/items/point_sack_intent.h @@ -31,4 +31,7 @@ void OnRedeem(coop::net::Session& session, const coop::net::PointSackRedeemPaylo void OnDisconnect(); +// HOST: the redemptions paid this session, for the sack drill. Game thread. +uint64_t PaidCount(); + } // namespace coop::point_sack_intent diff --git a/src/votv-coop/include/ue_wrap/engine/engine_mainplayer.h b/src/votv-coop/include/ue_wrap/engine/engine_mainplayer.h index 9aaca5f45..94248b748 100644 --- a/src/votv-coop/include/ue_wrap/engine/engine_mainplayer.h +++ b/src/votv-coop/include/ue_wrap/engine/engine_mainplayer.h @@ -72,6 +72,11 @@ bool CallMainPlayerUpdateHold(void* mainPlayer); // function does not resolve or the call fails. Game thread. bool CallMainPlayerUseSelectedAction(void* mainPlayer); +// Whether useSelectedAction would act now: the player's own selectedAction, true once its look-at actor +// answers lookAt and the action list built for it holds an action. The list is rebuilt after the look +// changes, so a press on the tick the trace lands finds none and the game denies it. Game thread. +bool MainPlayerHasSelectedAction(void* mainPlayer); + // The player's own pickup of `target`: `Hold Object` with `manual` set, which it takes before whatever the // player looks at, ending in the hand slot's equip (addEquip writes hold[0], updateHold spawns the hand item // as holding_actor) with the world actor destroyed. `collected` is the verb's own answer, false on each of diff --git a/src/votv-coop/sources.cmake b/src/votv-coop/sources.cmake index ff95bdf46..62108213c 100644 --- a/src/votv-coop/sources.cmake +++ b/src/votv-coop/sources.cmake @@ -540,6 +540,7 @@ set(VOTVCOOP_SOURCES src/coop/dev/dev_lanes.cpp src/coop/dev/physmods_drill.cpp src/coop/dev/drone_call_drill.cpp + src/coop/dev/sack_drill.cpp src/coop/dev/server_upgrade_drill.cpp src/coop/dev/server_drill.cpp src/coop/dev/sat_console_drill.cpp diff --git a/src/votv-coop/src/coop/dev/dev_lanes.cpp b/src/votv-coop/src/coop/dev/dev_lanes.cpp index 2d5f59914..1b7c94582 100644 --- a/src/votv-coop/src/coop/dev/dev_lanes.cpp +++ b/src/votv-coop/src/coop/dev/dev_lanes.cpp @@ -16,7 +16,8 @@ #include "coop/dev/desk_diag.h" // [dev] desk/console divergence census #include "coop/dev/door_drill.h" // [dev] whether a remote player counts in a door's own sensor #include "coop/dev/drive_selftest.h" // [dev] rack-lane e2e circles -#include "coop/dev/drone_call_drill.h" // [dev] a client's console press flies the host's drone +#include "coop/dev/drone_call_drill.h" +#include "coop/dev/sack_drill.h" // [dev] a client's point sack is paid by the host, once // [dev] a client's console press flies the host's drone #include "coop/dev/server_upgrade_drill.h" // [dev] a client's install and take-out at a server box #include "coop/dev/server_drill.h" // [dev] a server box's break is the host's; a client's repair runs there #include "coop/dev/sat_console_drill.h" // [dev] a client's SAT console commands run on the host @@ -137,6 +138,7 @@ void EndSession() { coop::dev::keypad_drill::OnDisconnect(); // [dev] the keypad and the legs belong to one world coop::dev::container_view_drill::OnDisconnect(); // [dev] the ATV, the view and the walks belong to one world coop::dev::physmods_drill::OnDisconnect(); // [dev] the client arms again, so a rejoin says its line + coop::dev::sack_drill::OnDisconnect(); // [dev] the sack and the counts belong to one session coop::dev::drone_call_drill::OnDisconnect(); // [dev] the console, the legs and the host's watch belong to one world coop::dev::server_upgrade_drill::OnDisconnect(); // [dev] the box and the legs belong to one world coop::dev::server_drill::OnDisconnect(); // [dev] the same for the server break drill @@ -219,6 +221,7 @@ void TickDrills(coop::net::Session& session) { coop::dev::container_opener_probe::Tick(&session); // [dev] far containers' openers and the reach to them (latched read when off) coop::dev::container_view_drill::Tick(&session); // [dev] the view-close drill (a single bool read when off) coop::dev::physmods_drill::Tick(&session); // [dev] the module-plug drill (a single bool read when off) + coop::dev::sack_drill::Tick(&session); // [dev] the point sack drill (a latched string compare when off) coop::dev::drone_call_drill::Tick(&session); // [dev] the drone console drill (a single bool read when off) coop::dev::server_upgrade_drill::Tick(&session); // [dev] the server upgrade drill (a single bool read when off) coop::dev::server_drill::Tick(&session); // [dev] the server break drill (two short string compares when off) diff --git a/src/votv-coop/src/coop/dev/sack_drill.cpp b/src/votv-coop/src/coop/dev/sack_drill.cpp new file mode 100644 index 000000000..6da686035 --- /dev/null +++ b/src/votv-coop/src/coop/dev/sack_drill.cpp @@ -0,0 +1,254 @@ +// coop/dev/sack_drill.cpp -- see coop/dev/sack_drill.h. + +#include "coop/dev/sack_drill.h" + +#include "coop/config/config.h" +#include "coop/config/config_registry.h" +#include "coop/dev/director/aim_fan.h" +#include "coop/items/point_sack_intent.h" +#include "coop/net/session.h" +#include "coop/player/players_registry.h" +#include "coop/player/puppet_drive.h" +#include "coop/player/remote_player.h" +#include "coop/session/join_progress.h" +#include "coop/session/net_pump.h" // HasAnnouncedWorldReady +#include "ue_wrap/core/asset_load.h" +#include "ue_wrap/core/cached_obj_ref.h" +#include "ue_wrap/core/log.h" +#include "ue_wrap/core/reflection.h" +#include "ue_wrap/core/reflection_props.h" +#include "ue_wrap/engine/engine.h" +#include "ue_wrap/world/economy.h" +#include "ue_wrap/world/world_singleton.h" + +#include + +#include +#include +#include +#include + +namespace coop::dev::sack_drill { +namespace { + +namespace E = ue_wrap::engine; +namespace R = ue_wrap::reflection; + +constexpr const wchar_t* kSackPath = L"/Game/objects/prop_pointSack.prop_pointSack_C"; +constexpr const wchar_t* kSackClass = L"prop_pointSack_C"; +constexpr float kAheadCm = 100.f; // in front of the puppet: inside the player's interaction reach +constexpr float kDropCm = 20.f; // above its feet's height, so it settles on the floor +constexpr uint64_t kPayBoundMs = 60000; // HOST: the spawn to the pay (the client's find, aim and press) +constexpr uint64_t kFindBoundMs = 60000; // CLIENT: world-ready to the sack's mirror +constexpr uint64_t kSeenBoundMs = 30000; // CLIENT: the press to its mirrored balance rising +constexpr uint64_t kReadyBoundMs = 3000; // CLIENT: the aim to the player's action list holding the sack's +constexpr uint64_t kCheckEveryMs = 250; +constexpr uint64_t kSettleBoundMs = 10000; // CLIENT: the mirror's find to it at rest (it is spawned above the floor) +constexpr int kRestChecks = 4; // ...that many reads in a row, a check apart, within a centimetre + +enum class Step : uint8_t { Arm, Find, Settle, Aim, Ready, Press, Seen, Done }; +Step g_step = Step::Arm; +uint64_t g_stepMs = 0; +uint64_t g_nextCheckMs = 0; +int g_session = 1; +int32_t g_points0 = 0; // this peer's balance before the sack +int32_t g_sackPoints = 0; // HOST: the sack's own `points` +uint64_t g_paid0 = 0; // HOST: paid redemptions before the sack +ue_wrap::CachedObjRef g_sack; // CLIENT: the sack's mirror +coop::director::AimFan g_fan; +ue_wrap::FVector g_lastAt{}; +int g_restReads = 0; +uint64_t g_nextReadMs = 0; + +bool Enabled() { + static const bool on = coop::config::ResolveString(::coop::config_registry::rows::sack_drill) == "run"; + return on; +} +void Next(Step s) { g_step = s; g_stepMs = ::GetTickCount64(); } +bool Expired(uint64_t bound) { return ::GetTickCount64() - g_stepMs > bound; } +void Fail(const char* what) { + UE_LOGW("[SACK-DRILL] FAIL in session %d: %s", g_session, what); + g_step = Step::Done; +} +void Abandon(const char* why) { + UE_LOGW("[SACK-DRILL] ABANDONED in session %d: %s", g_session, why); + g_step = Step::Done; +} + +// The sack class's `points`, the sum its own action pays. -1 unread. +int32_t SackPoints(void* sack) { + if (!sack || !R::IsLive(sack)) return -1; + const int32_t off = R::FindPropertyOffset(R::ClassOf(sack), L"points"); + if (off < 0) return -1; + int32_t v = 0; + std::memcpy(&v, static_cast(sack) + off, sizeof(v)); + return v; +} + +// The player's own look state, named when its action list never takes the sack: which of +// selectedAction's gates held (its look-at actor, the look flag, an open interface, the trace's hit). +void SayLookState(void* player, void* sack) { + void* cls = R::ClassOf(player); + const int32_t lookOff = R::FindPropertyOffset(cls, L"lookAtActor"); + const int32_t uiOff = R::FindPropertyOffset(cls, L"activeInterface"); + int32_t flagOff = -1; + uint8_t flagMask = 0; + void* look = nullptr; + void* ui = nullptr; + if (lookOff >= 0) std::memcpy(&look, static_cast(player) + lookOff, sizeof(look)); + if (uiOff >= 0) std::memcpy(&ui, static_cast(player) + uiOff, sizeof(ui)); + const bool flagRead = R::FindBoolProperty(cls, L"isLookingAt", flagOff, flagMask); + const int flag = flagRead ? ((static_cast(player)[flagOff] & flagMask) ? 1 : 0) : -1; + UE_LOGW("[SACK-DRILL] client: the look state -- lookAtActor=%p (the sack %p) isLookingAt=%d activeInterface=%p " + "trace hit=%p", look, sack, flag, ui, E::ReadMainPlayerHitActor(player)); +} + +void HostTick(coop::net::Session* s) { + if (g_step == Step::Done) return; + const uint64_t now = ::GetTickCount64(); + if (now < g_nextCheckMs) return; + g_nextCheckMs = now + kCheckEveryMs; + if (g_step == Step::Arm) { + if (!s->IsSlotWorldReady(1)) return; + void* puppet = coop::puppet_drive::Puppet(1).GetActor(); // spawned once the peer's pose arrives + ue_wrap::FVector at{}; + if (!puppet || !R::IsLive(puppet) || !E::TryGetActorLocation(puppet, at)) return; + void* cls = ue_wrap::asset_load::LoadObjectByPath(kSackPath); + if (!cls) { Abandon("the point sack's class does not load"); return; } + const ue_wrap::FVector fwd = E::GetActorForwardVector(puppet); + void* sack = E::SpawnActor(cls, {at.X + fwd.X * kAheadCm, at.Y + fwd.Y * kAheadCm, at.Z + kDropCm}); + g_sackPoints = SackPoints(sack); + if (!sack || g_sackPoints <= 0) { Abandon("the point sack did not spawn or its points are unread"); return; } + if (!ue_wrap::economy::ReadPoints(&g_points0)) { Abandon("the host's balance is unread"); return; } + g_paid0 = coop::point_sack_intent::PaidCount(); + UE_LOGI("[SACK-DRILL] host: a point sack worth %d spawned before the client's puppet; balance %d", + g_sackPoints, g_points0); + Next(Step::Seen); + return; + } + // Seen: the client's redemption paid here, once, by the sack's own sum. + const uint64_t paid = coop::point_sack_intent::PaidCount(); + if (paid == g_paid0) { + if (Expired(kPayBoundMs)) Fail("the client's sack was not paid on the host within 60 s"); + return; + } + int32_t now1 = 0; + if (!ue_wrap::economy::ReadPoints(&now1)) return; + if (paid != g_paid0 + 1 || now1 - g_points0 != g_sackPoints) { + char why[160]; + std::snprintf(why, sizeof(why), "the host paid %llu redemption(s) and its balance moved by %d, not one and %d", + static_cast(paid - g_paid0), now1 - g_points0, g_sackPoints); + Fail(why); + return; + } + UE_LOGI("[SACK-DRILL] host DONE in session %d: the client's sack paid here once, %d -> %d (+%d)", g_session, + g_points0, now1, g_sackPoints); + g_step = Step::Done; +} + +void ClientTick() { + if (g_step == Step::Done) return; + void* player = coop::players::Registry::Get().Local(); + if (!player) return; + switch (g_step) { + case Step::Arm: + if (!coop::net_pump::HasAnnouncedWorldReady() || + coop::join_progress::CurrentPhase() != coop::join_progress::Phase::Idle) + return; + Next(Step::Find); + return; + case Step::Find: { + void* sack = ue_wrap::world_singleton::Find(kSackClass); + if (!sack) { + if (Expired(kFindBoundMs)) Abandon("no point sack's mirror reached this client within 60 s"); + return; + } + if (!ue_wrap::economy::ReadPoints(&g_points0)) return; + g_sack.Set(sack); + g_restReads = 0; + UE_LOGI("[SACK-DRILL] client: the sack's mirror is here; balance %d", g_points0); + Next(Step::Settle); + return; + } + case Step::Settle: { // spawned above the floor: aimed at only once it lies still + const uint64_t now = ::GetTickCount64(); + if (now < g_nextReadMs) return; + g_nextReadMs = now + kCheckEveryMs; + ue_wrap::FVector at{}; + void* sack = g_sack.Get(); + if (!sack || !E::TryGetActorLocation(sack, at)) { Abandon("the sack's mirror died"); return; } + const float moved = std::fabs(at.X - g_lastAt.X) + std::fabs(at.Y - g_lastAt.Y) + std::fabs(at.Z - g_lastAt.Z); + g_restReads = moved < 1.f ? g_restReads + 1 : 0; + g_lastAt = at; + if (g_restReads >= kRestChecks) { + g_fan = coop::director::AimFan(); + Next(Step::Aim); + return; + } + if (Expired(kSettleBoundMs)) Abandon("the sack's mirror never came to rest"); + return; + } + case Step::Aim: { + ue_wrap::FVector at{}; + void* sack = g_sack.Get(); + if (!sack || !E::TryGetActorLocation(sack, at)) { Abandon("the sack's mirror died"); return; } + const bool aimed = E::ReadMainPlayerHitActor(player) == sack; + const auto st = g_fan.Tick(player, at, aimed); + if (st == coop::director::AimFan::State::Failed) { Abandon("no pose of the fan put the trace on the sack"); return; } + if (st == coop::director::AimFan::State::Aimed) Next(Step::Ready); + return; + } + case Step::Ready: // the player's own action list, rebuilt after the look changed, holds the sack's action + if (E::MainPlayerHasSelectedAction(player)) { Next(Step::Press); return; } + if (E::ReadMainPlayerHitActor(player) != g_sack.Get()) { // the trace left it: aim again + g_fan = coop::director::AimFan(); + Next(Step::Aim); + return; + } + if (Expired(kReadyBoundMs)) { + SayLookState(player, g_sack.Get()); + Abandon("the player's action list never held an action for the sack"); + } + return; + case Step::Press: + if (!E::CallMainPlayerUseSelectedAction(player)) { Abandon("useSelectedAction did not dispatch"); return; } + UE_LOGI("[SACK-DRILL] client pressed the sack"); + Next(Step::Seen); + return; + case Step::Seen: { + int32_t now1 = 0; + if (ue_wrap::economy::ReadPoints(&now1) && now1 > g_points0) { + UE_LOGI("[SACK-DRILL] client: the mirrored balance rose %d -> %d", g_points0, now1); + g_step = Step::Done; + return; + } + if (Expired(kSeenBoundMs)) Fail("this client's mirrored balance did not rise within 30 s of the press"); + return; + } + case Step::Done: + return; + } +} + +} // namespace + +void Tick(coop::net::Session* s) { + if (!Enabled() || !s) return; + if (s->role() == coop::net::Role::Host) HostTick(s); + else if (s->connected()) ClientTick(); +} + +void OnDisconnect() { + if (!Enabled()) return; + ++g_session; + g_step = Step::Arm; + g_stepMs = g_nextCheckMs = 0; + g_points0 = g_sackPoints = 0; + g_paid0 = 0; + g_sack.Reset(); + g_restReads = 0; + g_nextReadMs = 0; + g_lastAt = {}; +} + +} // namespace coop::dev::sack_drill diff --git a/src/votv-coop/src/coop/items/point_sack_intent.cpp b/src/votv-coop/src/coop/items/point_sack_intent.cpp index bbd19080c..2abf16824 100644 --- a/src/votv-coop/src/coop/items/point_sack_intent.cpp +++ b/src/votv-coop/src/coop/items/point_sack_intent.cpp @@ -2,6 +2,9 @@ #include "coop/items/point_sack_intent.h" +#include "coop/config/config.h" +#include "coop/config/config_registry.h" + #include "coop/element/intent_authority.h" // IntentTarget: the sender's reach to the sack #include "coop/element/registry.h" // LivePropActor, the eid fallback #include "coop/net/protocol.h" @@ -54,6 +57,10 @@ sg::Verdict OnActionPre(const sg::Call& c) { auto* s = g_session.load(std::memory_order_acquire); if (!s || !s->running() || s->role() != coop::net::Role::Client) return sg::Verdict::Run; if (!IsSack(c.object)) return sg::Verdict::Run; + // The sack drill's red: the client's own body runs, its credit landing in its mirror alone. + static const bool s_clientRuns = + coop::config::ResolveFlag(coop::config_registry::rows::point_sack_client_runs); + if (s_clientRuns) return sg::Verdict::Run; const std::wstring key = ue_wrap::prop::GetInteractableKeyString(c.object); // The registry's reverse names a mirror as well as a local: a sack loaded from the host's save or // received as a spawn is a mirror here, and a key-less one is named by this alone. @@ -157,6 +164,8 @@ void OnRedeem(coop::net::Session& session, const coop::net::PointSackRedeemPaylo p.elementId, points, static_cast(g_paid)); } +uint64_t PaidCount() { return g_paid; } + void OnDisconnect() { if (g_sent || g_paid || g_refused) UE_LOGI("point_sack: session end -- sent=%llu paid=%llu refused=%llu", static_cast(g_sent), diff --git a/src/votv-coop/src/ue_wrap/engine/engine_mainplayer.cpp b/src/votv-coop/src/ue_wrap/engine/engine_mainplayer.cpp index 775eb5fd5..10c897250 100644 --- a/src/votv-coop/src/ue_wrap/engine/engine_mainplayer.cpp +++ b/src/votv-coop/src/ue_wrap/engine/engine_mainplayer.cpp @@ -289,6 +289,14 @@ bool CallMainPlayerUseSelectedAction(void* mainPlayer) { return f.valid() && Call(mainPlayer, f); } +bool MainPlayerHasSelectedAction(void* mainPlayer) { + if (!mainPlayer || !R::IsLive(mainPlayer)) return false; + void* fn = R::FindDispatchFunctionCached(R::ClassOf(mainPlayer), L"selectedAction"); + if (!fn) return false; + ParamFrame f(fn); // the FString out param is written empty on both paths, so nothing is owned + return f.valid() && Call(mainPlayer, f) && f.Get(L"return"); +} + bool CallMainPlayerHoldObject(void* mainPlayer, void* target, bool& collected) { collected = false; if (!mainPlayer || !R::IsLive(mainPlayer) || !target) return false; From 5845f7a3745744b1204b0a730327cede665fc00f Mon Sep 17 00:00:00 2001 From: pelmentor Date: Sat, 10 Oct 2026 12:13:38 +0600 Subject: [PATCH 069/306] [dev] a fall drill proves a host birth's fall lands on the client L11's streamed fall had no drill. fall_drill=run has the host drop a drive 2 m ahead of and 4 m above its own player once the client's world is ready and say where it rests; the client takes the newest stream end applied here after it armed and passes when its copy of that drive rests within 5 cm of the host's final pose (prop_drive_stream::LastAppliedEnd). prop_fall_no_coast keeps the host from streaming a falling birth, the red: no end comes and the client fails. Co-Authored-By: Claude Opus 5.5 --- .../coop/config/config_registry_rows_dev.inc | 5 + src/votv-coop/include/coop/dev/fall_drill.h | 22 ++ .../include/coop/props/prop_drive_stream.h | 8 + src/votv-coop/sources.cmake | 1 + src/votv-coop/src/coop/dev/dev_lanes.cpp | 3 + src/votv-coop/src/coop/dev/fall_drill.cpp | 193 ++++++++++++++++++ .../src/coop/props/host_spawn_watcher.cpp | 5 + .../src/coop/props/prop_drive_stream.cpp | 19 ++ 8 files changed, 256 insertions(+) create mode 100644 src/votv-coop/include/coop/dev/fall_drill.h create mode 100644 src/votv-coop/src/coop/dev/fall_drill.cpp diff --git a/src/votv-coop/include/coop/config/config_registry_rows_dev.inc b/src/votv-coop/include/coop/config/config_registry_rows_dev.inc index a9bd994c8..28470f98e 100644 --- a/src/votv-coop/include/coop/config/config_registry_rows_dev.inc +++ b/src/votv-coop/include/coop/config/config_registry_rows_dev.inc @@ -469,6 +469,11 @@ CFG_FLAG(physmods_drill, "physmods_drill", "dev", false, "VOTVCOOP_PHYSMODS_DRIL "line says whether its array holds that one module alone. A joiner whose desk already holds modules at " "its world-ready (a rejoin into the drill's world) plugs nothing and says what it loaded. Lines tagged " "[PHYSMODS-DRILL].") +CFG_STRING(fall_drill, "fall_drill", "dev", "", "VOTVCOOP_FALL_DRILL", false, + "Drill (BOTH peers): a host birth that falls lands on the client where it lands on the host. run -- the host " + "drops a drive from 4 m once the client's world is ready; the client's DONE line ends it.") +CFG_FLAG(prop_fall_no_coast, "prop_fall_no_coast", "dev", false, "VOTVCOOP_PROP_FALL_NO_COAST", + "Developer: a falling host birth is not streamed to the clients (the fall drill's red).") CFG_STRING(sack_drill, "sack_drill", "dev", "", "VOTVCOOP_SACK_DRILL", false, "Drill (BOTH peers): a client's point sack is paid by the host, once. run -- the host spawns a sack before the " "client's puppet once its world is ready; the client aims and presses it; the host's DONE line ends it.") diff --git a/src/votv-coop/include/coop/dev/fall_drill.h b/src/votv-coop/include/coop/dev/fall_drill.h new file mode 100644 index 000000000..ead6f9734 --- /dev/null +++ b/src/votv-coop/include/coop/dev/fall_drill.h @@ -0,0 +1,22 @@ +// coop/dev/fall_drill.h -- [dev] a host birth that falls lands on the client where it lands on the host (L11). +// HOST -- once slot 1's world is ready and its puppet stands, spawns a drive 4 m up in front of its own player and +// says, once the drive is tracked and lies still, where it came to rest and how far it fell. +// CLIENT -- once its world is ready, waits for the first stream end the host applies here after that, and passes +// when its copy of that prop rests within 5 cm of the host's final pose the end carried. +// The red: prop_fall_no_coast=1 on the host streams no fall, so no end comes and the client fails. "[FALL-DRILL] +// FAIL" is the lane failing (--fail-marker), "[FALL-DRILL] ABANDONED" the drill unable to do its part (--dead-marker). +// Run with fall_drill=run and --done-marker "[FALL-DRILL] client DONE". + +#pragma once + +namespace coop::net { class Session; } + +namespace coop::dev::fall_drill { + +// Advances this peer's steps; a latched read when off. Game thread. +void Tick(coop::net::Session* s); + +// The session ended: the drill starts over. +void OnDisconnect(); + +} // namespace coop::dev::fall_drill diff --git a/src/votv-coop/include/coop/props/prop_drive_stream.h b/src/votv-coop/include/coop/props/prop_drive_stream.h index b1b156241..1cf4fd6e8 100644 --- a/src/votv-coop/include/coop/props/prop_drive_stream.h +++ b/src/votv-coop/include/coop/props/prop_drive_stream.h @@ -13,6 +13,10 @@ #pragma once +#include "ue_wrap/core/types.h" + +#include + namespace coop::net { class Session; struct PropDriveEndPayload; @@ -34,4 +38,8 @@ bool IsParked(void* actor); // Session end: every driven prop gets its physics back. Game thread. void OnDisconnect(); +// The last stream end applied here: its eid, the host's final pose and the tick it landed +// (GetTickCount64). False before any. For the fall drill's verdict. Game thread. +bool LastAppliedEnd(uint32_t* eid, ue_wrap::FVector* pose, uint64_t* atMs); + } // namespace coop::prop_drive_stream diff --git a/src/votv-coop/sources.cmake b/src/votv-coop/sources.cmake index 62108213c..72032d77a 100644 --- a/src/votv-coop/sources.cmake +++ b/src/votv-coop/sources.cmake @@ -541,6 +541,7 @@ set(VOTVCOOP_SOURCES src/coop/dev/physmods_drill.cpp src/coop/dev/drone_call_drill.cpp src/coop/dev/sack_drill.cpp + src/coop/dev/fall_drill.cpp src/coop/dev/server_upgrade_drill.cpp src/coop/dev/server_drill.cpp src/coop/dev/sat_console_drill.cpp diff --git a/src/votv-coop/src/coop/dev/dev_lanes.cpp b/src/votv-coop/src/coop/dev/dev_lanes.cpp index 1b7c94582..2078e5c55 100644 --- a/src/votv-coop/src/coop/dev/dev_lanes.cpp +++ b/src/votv-coop/src/coop/dev/dev_lanes.cpp @@ -17,6 +17,7 @@ #include "coop/dev/door_drill.h" // [dev] whether a remote player counts in a door's own sensor #include "coop/dev/drive_selftest.h" // [dev] rack-lane e2e circles #include "coop/dev/drone_call_drill.h" +#include "coop/dev/fall_drill.h" // [dev] a falling host birth lands on the client where it lands on the host #include "coop/dev/sack_drill.h" // [dev] a client's point sack is paid by the host, once // [dev] a client's console press flies the host's drone #include "coop/dev/server_upgrade_drill.h" // [dev] a client's install and take-out at a server box #include "coop/dev/server_drill.h" // [dev] a server box's break is the host's; a client's repair runs there @@ -138,6 +139,7 @@ void EndSession() { coop::dev::keypad_drill::OnDisconnect(); // [dev] the keypad and the legs belong to one world coop::dev::container_view_drill::OnDisconnect(); // [dev] the ATV, the view and the walks belong to one world coop::dev::physmods_drill::OnDisconnect(); // [dev] the client arms again, so a rejoin says its line + coop::dev::fall_drill::OnDisconnect(); // [dev] the drop belongs to one session coop::dev::sack_drill::OnDisconnect(); // [dev] the sack and the counts belong to one session coop::dev::drone_call_drill::OnDisconnect(); // [dev] the console, the legs and the host's watch belong to one world coop::dev::server_upgrade_drill::OnDisconnect(); // [dev] the box and the legs belong to one world @@ -221,6 +223,7 @@ void TickDrills(coop::net::Session& session) { coop::dev::container_opener_probe::Tick(&session); // [dev] far containers' openers and the reach to them (latched read when off) coop::dev::container_view_drill::Tick(&session); // [dev] the view-close drill (a single bool read when off) coop::dev::physmods_drill::Tick(&session); // [dev] the module-plug drill (a single bool read when off) + coop::dev::fall_drill::Tick(&session); // [dev] the fall drill (a latched string compare when off) coop::dev::sack_drill::Tick(&session); // [dev] the point sack drill (a latched string compare when off) coop::dev::drone_call_drill::Tick(&session); // [dev] the drone console drill (a single bool read when off) coop::dev::server_upgrade_drill::Tick(&session); // [dev] the server upgrade drill (a single bool read when off) diff --git a/src/votv-coop/src/coop/dev/fall_drill.cpp b/src/votv-coop/src/coop/dev/fall_drill.cpp new file mode 100644 index 000000000..6f0e41b68 --- /dev/null +++ b/src/votv-coop/src/coop/dev/fall_drill.cpp @@ -0,0 +1,193 @@ +// coop/dev/fall_drill.cpp -- see coop/dev/fall_drill.h. + +#include "coop/dev/fall_drill.h" + +#include "coop/config/config.h" +#include "coop/config/config_registry.h" +#include "coop/net/session.h" +#include "coop/player/players_registry.h" +#include "coop/player/puppet_drive.h" +#include "coop/player/remote_player.h" +#include "coop/props/prop_drive_stream.h" +#include "coop/props/prop_element_tracker.h" +#include "coop/props/remote_prop.h" +#include "coop/session/join_progress.h" +#include "coop/session/net_pump.h" // HasAnnouncedWorldReady +#include "ue_wrap/core/cached_obj_ref.h" +#include "ue_wrap/core/log.h" +#include "ue_wrap/core/reflection.h" +#include "ue_wrap/engine/engine.h" + +#include + +#include +#include +#include + +namespace coop::dev::fall_drill { +namespace { + +namespace E = ue_wrap::engine; +namespace R = ue_wrap::reflection; + +constexpr const wchar_t* kDriveCls = L"prop_drive_C"; +constexpr float kAheadCm = 200.f; // in front of the host's player +constexpr float kDropCm = 400.f; // above its feet's height: a fall the stream must carry +constexpr float kTolCm = 5.f; // the client's copy at rest against the host's final pose +constexpr uint64_t kTrackBoundMs = 5000; // HOST: the spawn to the drive's eid +constexpr uint64_t kRestBoundMs = 20000; // HOST: the eid to the drive lying still +constexpr uint64_t kEndBoundMs = 60000; // CLIENT: world-ready to the host's stream end +constexpr uint64_t kCheckEveryMs = 250; +constexpr int kRestChecks = 4; // reads in a row, a check apart, within a centimetre + +enum class Step : uint8_t { Arm, Track, Rest, End, Done }; +Step g_step = Step::Arm; +uint64_t g_stepMs = 0; +uint64_t g_nextCheckMs = 0; +uint64_t g_armMs = 0; // CLIENT: when it armed; an end before this is not the drill's +int g_session = 1; +ue_wrap::CachedObjRef g_drive; // HOST: the dropped drive +uint32_t g_eid = 0; +float g_fromZ = 0.f; +ue_wrap::FVector g_lastAt{}; +int g_restReads = 0; + +bool Enabled() { + static const bool on = coop::config::ResolveString(::coop::config_registry::rows::fall_drill) == "run"; + return on; +} +void Next(Step s) { g_step = s; g_stepMs = ::GetTickCount64(); } +bool Expired(uint64_t bound) { return ::GetTickCount64() - g_stepMs > bound; } +void Fail(const char* what) { + UE_LOGW("[FALL-DRILL] FAIL in session %d: %s", g_session, what); + g_step = Step::Done; +} +void Abandon(const char* why) { + UE_LOGW("[FALL-DRILL] ABANDONED in session %d: %s", g_session, why); + g_step = Step::Done; +} +float Dist(const ue_wrap::FVector& a, const ue_wrap::FVector& b) { + return std::sqrt((a.X - b.X) * (a.X - b.X) + (a.Y - b.Y) * (a.Y - b.Y) + (a.Z - b.Z) * (a.Z - b.Z)); +} + +void HostTick(coop::net::Session* s) { + switch (g_step) { + case Step::Arm: { + if (!s->IsSlotWorldReady(1)) return; + void* puppet = coop::puppet_drive::Puppet(1).GetActor(); // the peer's pose is arriving: its stream reads + void* me = coop::players::Registry::Get().Local(); + ue_wrap::FVector at{}; + if (!puppet || !R::IsLive(puppet) || !me || !E::TryGetActorLocation(me, at)) return; + void* cls = R::FindClass(kDriveCls); + if (!cls) { Abandon("the drive's class is not loaded"); return; } + const ue_wrap::FVector fwd = E::GetActorForwardVector(me); + const ue_wrap::FVector from{at.X + fwd.X * kAheadCm, at.Y + fwd.Y * kAheadCm, at.Z + kDropCm}; + void* drive = E::SpawnActor(cls, from); + if (!drive) { Abandon("the drive did not spawn"); return; } + g_drive.Set(drive); + g_fromZ = from.Z; + UE_LOGI("[FALL-DRILL] host: a drive dropped from (%.0f,%.0f,%.0f)", from.X, from.Y, from.Z); + Next(Step::Track); + return; + } + case Step::Track: { + void* drive = g_drive.Get(); + if (!drive) { Abandon("the dropped drive died"); return; } + g_eid = static_cast(coop::prop_element_tracker::GetPropElementIdForActor(drive)); + if (g_eid == 0) { + if (Expired(kTrackBoundMs)) Fail("the dropped drive got no eid within 5 s"); + return; + } + g_restReads = 0; + Next(Step::Rest); + return; + } + case Step::Rest: { + const uint64_t now = ::GetTickCount64(); + if (now < g_nextCheckMs) return; + g_nextCheckMs = now + kCheckEveryMs; + void* drive = g_drive.Get(); + ue_wrap::FVector at{}; + if (!drive || !E::TryGetActorLocation(drive, at)) { Abandon("the dropped drive died"); return; } + g_restReads = Dist(at, g_lastAt) < 1.f ? g_restReads + 1 : 0; + g_lastAt = at; + if (g_restReads >= kRestChecks) { + UE_LOGI("[FALL-DRILL] host: the drive eid=%u fell %.0f cm and rests at (%.1f,%.1f,%.1f)", g_eid, + g_fromZ - at.Z, at.X, at.Y, at.Z); + g_step = Step::Done; + return; + } + if (Expired(kRestBoundMs)) Abandon("the dropped drive never came to rest"); + return; + } + case Step::End: + case Step::Done: + return; + } +} + +void ClientTick() { + switch (g_step) { + case Step::Arm: + if (!coop::net_pump::HasAnnouncedWorldReady() || + coop::join_progress::CurrentPhase() != coop::join_progress::Phase::Idle) + return; + g_armMs = ::GetTickCount64(); + Next(Step::End); + return; + case Step::End: { + const uint64_t now = ::GetTickCount64(); + if (now < g_nextCheckMs) return; + g_nextCheckMs = now + kCheckEveryMs; + uint32_t eid = 0; + ue_wrap::FVector pose{}; + uint64_t atMs = 0; + void* actor = nullptr; + if (coop::prop_drive_stream::LastAppliedEnd(&eid, &pose, &atMs) && atMs > g_armMs) + actor = coop::remote_prop::ResolveLiveActorByEid(eid); + if (!actor || R::ClassNameOf(actor) != kDriveCls) { + if (Expired(kEndBoundMs)) Fail("no stream end of a fallen drive came from the host within 60 s"); + return; + } + ue_wrap::FVector here{}; + if (!E::TryGetActorLocation(actor, here)) return; + const float off = Dist(here, pose); + if (off > kTolCm) { + char why[160]; + std::snprintf(why, sizeof(why), "this client's drive eid=%u rests %.1f cm from the host's final pose", eid, off); + Fail(why); + return; + } + UE_LOGI("[FALL-DRILL] client DONE in session %d: the host's fallen drive eid=%u rests here at (%.1f,%.1f,%.1f), " + "%.1f cm from the host's final pose -- PASS", g_session, eid, here.X, here.Y, here.Z, off); + g_step = Step::Done; + return; + } + case Step::Track: + case Step::Rest: + case Step::Done: + return; + } +} + +} // namespace + +void Tick(coop::net::Session* s) { + if (!Enabled() || !s) return; + if (s->role() == coop::net::Role::Host) HostTick(s); + else if (s->connected()) ClientTick(); +} + +void OnDisconnect() { + if (!Enabled()) return; + ++g_session; + g_step = Step::Arm; + g_stepMs = g_nextCheckMs = g_armMs = 0; + g_drive.Reset(); + g_eid = 0; + g_fromZ = 0.f; + g_lastAt = {}; + g_restReads = 0; +} + +} // namespace coop::dev::fall_drill diff --git a/src/votv-coop/src/coop/props/host_spawn_watcher.cpp b/src/votv-coop/src/coop/props/host_spawn_watcher.cpp index 8290ce8b7..b406f6e08 100644 --- a/src/votv-coop/src/coop/props/host_spawn_watcher.cpp +++ b/src/votv-coop/src/coop/props/host_spawn_watcher.cpp @@ -2,6 +2,8 @@ #include "coop/props/host_spawn_watcher.h" +#include "coop/config/config.h" +#include "coop/config/config_registry.h" #include "coop/element/element.h" #include "coop/element/registry.h" // EidForActor (drain: tracked/mirror exclusion) #include "coop/net/protocol.h" @@ -386,6 +388,9 @@ void CoastIfFalling(void* actor) { return; } if (PT::GetPropElementIdForActor(actor) == coop::element::kInvalidId) return; + // The fall drill's red: a falling birth is not streamed, so no copy gets the host's final pose. + static const bool s_noCoast = coop::config::ResolveFlag(coop::config_registry::rows::prop_fall_no_coast); + if (s_noCoast) return; // Simulating and awake, which also means neither frozen, static nor asleep: a body born resting, // or one that has landed by the drain, opens no row. if (!(coop::prop_wire_parity::PhysFlagsOf(actor) & pf::kSimulatePhysics)) return; diff --git a/src/votv-coop/src/coop/props/prop_drive_stream.cpp b/src/votv-coop/src/coop/props/prop_drive_stream.cpp index 49b1be33f..6826fe257 100644 --- a/src/votv-coop/src/coop/props/prop_drive_stream.cpp +++ b/src/votv-coop/src/coop/props/prop_drive_stream.cpp @@ -17,6 +17,8 @@ #include "ue_wrap/core/reflection.h" #include "ue_wrap/engine/engine.h" +#include + #include #include #include @@ -125,8 +127,17 @@ bool YieldedAtOrAfter(uint32_t eid, uint8_t gen) { // parity restore the join's converge applies after its teleport, then the velocity -- only into // a body this module parked and the flags leave simulating, and only when it is not zero, since // assigning a velocity wakes a body at rest. +struct AppliedEnd { + bool have = false; + uint32_t eid = 0; + ue_wrap::FVector pose{}; + uint64_t atMs = 0; +}; +AppliedEnd g_lastEnd; + void ApplyEnd(void* actor, const coop::net::PropDriveEndPayload& p, bool parkedHere) { E::SetActorLocation(actor, ue_wrap::FVector{p.x, p.y, p.z}); + g_lastEnd = AppliedEnd{true, p.eid, ue_wrap::FVector{p.x, p.y, p.z}, ::GetTickCount64()}; E::SetActorRotation(actor, ue_wrap::FRotator{p.pitch, p.yaw, p.roll}); // The host's frozen and sleep at the end first: a hook that bites a frozen prop unfreezes it on // the host only (hook_C's attach_a runs setPropProps), so a copy still frozen here takes the @@ -147,6 +158,14 @@ void ApplyEnd(void* actor, const coop::net::PropDriveEndPayload& p, bool parkedH } // namespace +bool LastAppliedEnd(uint32_t* eid, ue_wrap::FVector* pose, uint64_t* atMs) { + if (!g_lastEnd.have) return false; + *eid = g_lastEnd.eid; + *pose = g_lastEnd.pose; + *atMs = g_lastEnd.atMs; + return true; +} + void TickApplyAndDrive(coop::net::Session& s) { UE_ASSERT_GAME_THREAD("prop_drive_stream::TickApplyAndDrive"); const uint64_t nowMs = AD::NowMs(); From 080e15ceee73a9806cb20241e784331aeda84f92 Mon Sep 17 00:00:00 2001 From: pelmentor Date: Sat, 10 Oct 2026 12:24:47 +0600 Subject: [PATCH 070/306] [dev] an ATV spawn drill proves a runtime ATV reaches a client once L21's runtime ATV had no drill. atv_spawn_drill has the host spawn an ATV_C in front of its player: run once the client's world is ready (the broadcast, or the park's hand-back when it lands before the client's own world reads ready), join while it is still loading (the world-ready replay of every runtime ATV). The client passes when exactly one runtime mirror stands once nothing waits in its park. park is run with atv_park_first_spawn on the client, which parks the first spawn reaching its ready world: the pass also needs the park's hand-back. Co-Authored-By: Claude Opus 5.5 --- .../coop/config/config_registry_rows_dev.inc | 7 + .../include/coop/dev/atv_spawn_drill.h | 23 +++ .../include/coop/interactables/atv_sync.h | 8 + src/votv-coop/sources.cmake | 1 + .../src/coop/dev/atv_spawn_drill.cpp | 146 ++++++++++++++++++ src/votv-coop/src/coop/dev/dev_lanes.cpp | 3 + .../src/coop/interactables/atv_spawn_park.cpp | 5 + .../src/coop/interactables/atv_spawn_park.h | 5 + .../src/coop/interactables/atv_sync.cpp | 23 +++ 9 files changed, 221 insertions(+) create mode 100644 src/votv-coop/include/coop/dev/atv_spawn_drill.h create mode 100644 src/votv-coop/src/coop/dev/atv_spawn_drill.cpp diff --git a/src/votv-coop/include/coop/config/config_registry_rows_dev.inc b/src/votv-coop/include/coop/config/config_registry_rows_dev.inc index 28470f98e..f7e5f8132 100644 --- a/src/votv-coop/include/coop/config/config_registry_rows_dev.inc +++ b/src/votv-coop/include/coop/config/config_registry_rows_dev.inc @@ -469,6 +469,13 @@ CFG_FLAG(physmods_drill, "physmods_drill", "dev", false, "VOTVCOOP_PHYSMODS_DRIL "line says whether its array holds that one module alone. A joiner whose desk already holds modules at " "its world-ready (a rejoin into the drill's world) plugs nothing and says what it loaded. Lines tagged " "[PHYSMODS-DRILL].") +CFG_STRING(atv_spawn_drill, "atv_spawn_drill", "dev", "", "VOTVCOOP_ATV_SPAWN_DRILL", false, + "Drill (BOTH peers): a runtime ATV spawned on the host reaches each client once. run -- spawned once the client's " + "world is ready; join -- while it is still loading (the world-ready replay); park -- as run, with " + "atv_park_first_spawn=1 on the client. The client's DONE line ends it.") +CFG_FLAG(atv_park_first_spawn, "atv_park_first_spawn", "dev", false, "VOTVCOOP_ATV_PARK_FIRST_SPAWN", + "Developer: the first runtime ATV spawn this client receives is parked as if its world were not ready (the ATV " + "spawn drill's park arm).") CFG_STRING(fall_drill, "fall_drill", "dev", "", "VOTVCOOP_FALL_DRILL", false, "Drill (BOTH peers): a host birth that falls lands on the client where it lands on the host. run -- the host " "drops a drive from 4 m once the client's world is ready; the client's DONE line ends it.") diff --git a/src/votv-coop/include/coop/dev/atv_spawn_drill.h b/src/votv-coop/include/coop/dev/atv_spawn_drill.h new file mode 100644 index 000000000..9e776c34d --- /dev/null +++ b/src/votv-coop/include/coop/dev/atv_spawn_drill.h @@ -0,0 +1,23 @@ +// coop/dev/atv_spawn_drill.h -- [dev] an ATV spawned at runtime on the host reaches each client once (L21). +// HOST -- spawns an ATV_C in front of its own player: run, once slot 1's world is ready (the spawn's broadcast); +// join, once slot 1 is seated and still loading (the world-ready replay of every runtime ATV); park, as +// run. It says when its ATV lane announced the ATV under a synthetic key. +// CLIENT -- once its world is ready and no runtime spawn waits in the park, passes when exactly one runtime ATV +// mirror stands here; in park mode atv_park_first_spawn=1 parks the first spawn as if the world were not +// ready, and the pass also needs the park to have held it and handed it back. +// "[ATV-SPAWN-DRILL] FAIL" is the lane failing (--fail-marker), "[ATV-SPAWN-DRILL] ABANDONED" the drill unable to do +// its part (--dead-marker). Run with atv_spawn_drill=run|join|park and --done-marker "[ATV-SPAWN-DRILL] client DONE". + +#pragma once + +namespace coop::net { class Session; } + +namespace coop::dev::atv_spawn_drill { + +// Advances this peer's steps; a latched read when off. Game thread. +void Tick(coop::net::Session* s); + +// The session ended: the drill starts over. +void OnDisconnect(); + +} // namespace coop::dev::atv_spawn_drill diff --git a/src/votv-coop/include/coop/interactables/atv_sync.h b/src/votv-coop/include/coop/interactables/atv_sync.h index 14a9ef399..847c74a1c 100644 --- a/src/votv-coop/include/coop/interactables/atv_sync.h +++ b/src/votv-coop/include/coop/interactables/atv_sync.h @@ -92,4 +92,12 @@ bool IsOccupiedByOther(void* actor, uint8_t* outOccupantSlot = nullptr); // an instrumented run can state which side of the mirror each sample came from. Game thread. bool OwnsTick(void* actor); +// For the ATV spawn drill, game thread: HOST, the runtime ATVs announced under a synthetic key this +// session; CLIENT, the live runtime ATV mirrors this peer fresh-spawned, the spawns its park ever +// held, and those it holds now. +uint32_t RuntimeAnnounced(); +int RuntimeMirrors(); +uint32_t ParkedEver(); +size_t ParkPending(); + } // namespace coop::atv_sync diff --git a/src/votv-coop/sources.cmake b/src/votv-coop/sources.cmake index 72032d77a..8da7b96cd 100644 --- a/src/votv-coop/sources.cmake +++ b/src/votv-coop/sources.cmake @@ -542,6 +542,7 @@ set(VOTVCOOP_SOURCES src/coop/dev/drone_call_drill.cpp src/coop/dev/sack_drill.cpp src/coop/dev/fall_drill.cpp + src/coop/dev/atv_spawn_drill.cpp src/coop/dev/server_upgrade_drill.cpp src/coop/dev/server_drill.cpp src/coop/dev/sat_console_drill.cpp diff --git a/src/votv-coop/src/coop/dev/atv_spawn_drill.cpp b/src/votv-coop/src/coop/dev/atv_spawn_drill.cpp new file mode 100644 index 000000000..6350fa886 --- /dev/null +++ b/src/votv-coop/src/coop/dev/atv_spawn_drill.cpp @@ -0,0 +1,146 @@ +// coop/dev/atv_spawn_drill.cpp -- see coop/dev/atv_spawn_drill.h. + +#include "coop/dev/atv_spawn_drill.h" + +#include "coop/config/config.h" +#include "coop/config/config_registry.h" +#include "coop/interactables/atv_sync.h" +#include "coop/net/session.h" +#include "coop/player/players_registry.h" +#include "coop/session/join_progress.h" +#include "coop/session/net_pump.h" // HasAnnouncedWorldReady +#include "ue_wrap/core/log.h" +#include "ue_wrap/core/object_index.h" +#include "ue_wrap/engine/engine.h" + +#include + +#include +#include + +namespace coop::dev::atv_spawn_drill { +namespace { + +namespace E = ue_wrap::engine; + +constexpr float kAheadCm = 600.f; // in front of the host's player, clear of it +constexpr float kLiftCm = 50.f; +constexpr uint64_t kAnnounceBound = 30000; // HOST: the spawn to the lane's announce +constexpr uint64_t kMirrorBound = 60000; // CLIENT: world-ready to the mirror +constexpr uint64_t kCheckEveryMs = 250; + +enum class Step : uint8_t { Arm, Announce, Mirror, Done }; +Step g_step = Step::Arm; +uint64_t g_stepMs = 0; +uint64_t g_nextCheckMs = 0; +int g_session = 1; +uint32_t g_announced0 = 0; // HOST: runtime ATVs announced before the drill's +uint32_t g_parked0 = 0; // CLIENT: spawns parked before this session's first tick +bool g_baselined = false; + +const std::string& Mode() { + static const std::string s = coop::config::ResolveString(::coop::config_registry::rows::atv_spawn_drill); + return s; +} +bool Enabled() { return Mode() == "run" || Mode() == "join" || Mode() == "park"; } +void Next(Step s) { g_step = s; g_stepMs = ::GetTickCount64(); } +bool Expired(uint64_t bound) { return ::GetTickCount64() - g_stepMs > bound; } +void Fail(const char* what) { + UE_LOGW("[ATV-SPAWN-DRILL] FAIL in session %d: %s", g_session, what); + g_step = Step::Done; +} +void Abandon(const char* why) { + UE_LOGW("[ATV-SPAWN-DRILL] ABANDONED in session %d: %s", g_session, why); + g_step = Step::Done; +} +bool Due() { + const uint64_t now = ::GetTickCount64(); + if (now < g_nextCheckMs) return false; + g_nextCheckMs = now + kCheckEveryMs; + return true; +} + +void HostTick(coop::net::Session* s) { + if (g_step == Step::Done || !Due()) return; + if (g_step == Step::Arm) { + // join: the spawn lands while slot 1 is seated and loading, so only the world-ready replay carries it. + const bool ready = Mode() == "join" ? (s->IsSlotReady(1) && !s->IsSlotWorldReady(1)) : s->IsSlotWorldReady(1); + if (!ready) return; + void* me = coop::players::Registry::Get().Local(); + ue_wrap::FVector at{}; + if (!me || !E::TryGetActorLocation(me, at)) return; + void* cls = ue_wrap::object_index::ClassByName(L"ATV_C"); + if (!cls) { Abandon("the ATV's class is not loaded"); return; } + g_announced0 = coop::atv_sync::RuntimeAnnounced(); + const ue_wrap::FVector fwd = E::GetActorForwardVector(me); + if (!E::SpawnActor(cls, {at.X + fwd.X * kAheadCm, at.Y + fwd.Y * kAheadCm, at.Z + kLiftCm})) { + Abandon("the ATV did not spawn"); + return; + } + UE_LOGI("[ATV-SPAWN-DRILL] host (%s): an ATV spawned in front of this player", Mode().c_str()); + Next(Step::Announce); + return; + } + if (coop::atv_sync::RuntimeAnnounced() > g_announced0) { + UE_LOGI("[ATV-SPAWN-DRILL] host: the ATV lane announced the spawn under a synthetic key"); + g_step = Step::Done; + return; + } + if (Expired(kAnnounceBound)) Fail("the host's ATV lane did not announce the runtime spawn within 30 s"); +} + +void ClientTick() { + if (g_step == Step::Done) return; + // The park baseline is taken at the session's first tick, before this world loads: the host spawns on its own + // view of the slot's world-ready, which can come before this peer's own, so the spawn may land before the arm. + if (!g_baselined) { + g_baselined = true; + g_parked0 = coop::atv_sync::ParkedEver(); + } + if (g_step == Step::Arm) { + if (!coop::net_pump::HasAnnouncedWorldReady() || + coop::join_progress::CurrentPhase() != coop::join_progress::Phase::Idle) + return; + Next(Step::Mirror); + return; + } + if (!Due()) return; + // Both ways in have run once nothing waits in the park: the broadcast or replay spawned, and a park handed back. + const int mirrors = coop::atv_sync::RuntimeMirrors(); + const bool parkedOk = Mode() != "park" || coop::atv_sync::ParkedEver() > g_parked0; + if (mirrors >= 1 && coop::atv_sync::ParkPending() == 0 && parkedOk) { + if (mirrors != 1) { + char why[120]; + std::snprintf(why, sizeof(why), "%d runtime ATV mirrors stand here for one spawn", mirrors); + Fail(why); + return; + } + UE_LOGI("[ATV-SPAWN-DRILL] client DONE in session %d (%s): one runtime ATV mirror stands here%s -- PASS", + g_session, Mode().c_str(), Mode() == "park" ? ", the park having held and handed back its spawn" : ""); + g_step = Step::Done; + return; + } + if (Expired(kMirrorBound)) + Fail(mirrors < 1 ? "no runtime ATV mirror stood here within 60 s of world-ready" + : !parkedOk ? "the park never held the runtime ATV's spawn" + : "a runtime ATV spawn still waited in the park 60 s after world-ready"); +} + +} // namespace + +void Tick(coop::net::Session* s) { + if (!Enabled() || !s) return; + if (s->role() == coop::net::Role::Host) HostTick(s); + else if (s->connected()) ClientTick(); +} + +void OnDisconnect() { + if (!Enabled()) return; + ++g_session; + g_step = Step::Arm; + g_stepMs = g_nextCheckMs = 0; + g_announced0 = g_parked0 = 0; + g_baselined = false; +} + +} // namespace coop::dev::atv_spawn_drill diff --git a/src/votv-coop/src/coop/dev/dev_lanes.cpp b/src/votv-coop/src/coop/dev/dev_lanes.cpp index 2078e5c55..de18319db 100644 --- a/src/votv-coop/src/coop/dev/dev_lanes.cpp +++ b/src/votv-coop/src/coop/dev/dev_lanes.cpp @@ -17,6 +17,7 @@ #include "coop/dev/door_drill.h" // [dev] whether a remote player counts in a door's own sensor #include "coop/dev/drive_selftest.h" // [dev] rack-lane e2e circles #include "coop/dev/drone_call_drill.h" +#include "coop/dev/atv_spawn_drill.h" // [dev] a runtime ATV reaches each client once #include "coop/dev/fall_drill.h" // [dev] a falling host birth lands on the client where it lands on the host #include "coop/dev/sack_drill.h" // [dev] a client's point sack is paid by the host, once // [dev] a client's console press flies the host's drone #include "coop/dev/server_upgrade_drill.h" // [dev] a client's install and take-out at a server box @@ -139,6 +140,7 @@ void EndSession() { coop::dev::keypad_drill::OnDisconnect(); // [dev] the keypad and the legs belong to one world coop::dev::container_view_drill::OnDisconnect(); // [dev] the ATV, the view and the walks belong to one world coop::dev::physmods_drill::OnDisconnect(); // [dev] the client arms again, so a rejoin says its line + coop::dev::atv_spawn_drill::OnDisconnect(); // [dev] the spawn belongs to one session coop::dev::fall_drill::OnDisconnect(); // [dev] the drop belongs to one session coop::dev::sack_drill::OnDisconnect(); // [dev] the sack and the counts belong to one session coop::dev::drone_call_drill::OnDisconnect(); // [dev] the console, the legs and the host's watch belong to one world @@ -223,6 +225,7 @@ void TickDrills(coop::net::Session& session) { coop::dev::container_opener_probe::Tick(&session); // [dev] far containers' openers and the reach to them (latched read when off) coop::dev::container_view_drill::Tick(&session); // [dev] the view-close drill (a single bool read when off) coop::dev::physmods_drill::Tick(&session); // [dev] the module-plug drill (a single bool read when off) + coop::dev::atv_spawn_drill::Tick(&session); // [dev] the ATV spawn drill (a latched string compare when off) coop::dev::fall_drill::Tick(&session); // [dev] the fall drill (a latched string compare when off) coop::dev::sack_drill::Tick(&session); // [dev] the point sack drill (a latched string compare when off) coop::dev::drone_call_drill::Tick(&session); // [dev] the drone console drill (a single bool read when off) diff --git a/src/votv-coop/src/coop/interactables/atv_spawn_park.cpp b/src/votv-coop/src/coop/interactables/atv_spawn_park.cpp index 3393ea13a..1d6a1c097 100644 --- a/src/votv-coop/src/coop/interactables/atv_spawn_park.cpp +++ b/src/votv-coop/src/coop/interactables/atv_spawn_park.cpp @@ -21,6 +21,7 @@ constexpr size_t kCap = 16; constexpr uint64_t kPollMs = 500; uint64_t g_nextPollMs = 0; bool g_saidCap = false; +uint32_t g_parkedEver = 0; uint64_t NowMs() { return static_cast(std::chrono::duration_cast( @@ -43,6 +44,7 @@ void Park(const std::wstring& key, const coop::net::AtvSpawnPayload& payload, co } UE_LOGI("atv: runtime-ATV synthKey='%ls' parked until this world is ready (%s)", key.c_str(), why); g_parked.emplace(key, Parked{payload, gen}); + ++g_parkedEver; return; } it->second.payload = payload; // the newest description of this ATV wins @@ -51,6 +53,9 @@ void Park(const std::wstring& key, const coop::net::AtvSpawnPayload& payload, co void Discard(const std::wstring& key) { g_parked.erase(key); } +uint32_t ParkedEver() { return g_parkedEver; } +size_t Pending() { return g_parked.size(); } + void DrainReady() { UE_ASSERT_GAME_THREAD("atv_spawn_park::DrainReady"); if (g_parked.empty()) return; diff --git a/src/votv-coop/src/coop/interactables/atv_spawn_park.h b/src/votv-coop/src/coop/interactables/atv_spawn_park.h index d838964d7..bb78aae6f 100644 --- a/src/votv-coop/src/coop/interactables/atv_spawn_park.h +++ b/src/votv-coop/src/coop/interactables/atv_spawn_park.h @@ -8,6 +8,8 @@ // ready; a world change drops it, the new world's replay bringing it again. A spawn that fails once its world is // ready is final: nothing here retries it. Game thread. #pragma once +#include +#include #include namespace coop::net { struct AtvSpawnPayload; } namespace coop::atv_sync::spawn_park { @@ -19,4 +21,7 @@ void Discard(const std::wstring& key); // still not ready. A half-second poll of that readiness. void DrainReady(); void Clear(); +// The spawns ever parked, and those parked now. For the ATV spawn drill. +uint32_t ParkedEver(); +size_t Pending(); } // namespace coop::atv_sync::spawn_park diff --git a/src/votv-coop/src/coop/interactables/atv_sync.cpp b/src/votv-coop/src/coop/interactables/atv_sync.cpp index e51973bf4..17bf1f0c4 100644 --- a/src/votv-coop/src/coop/interactables/atv_sync.cpp +++ b/src/votv-coop/src/coop/interactables/atv_sync.cpp @@ -15,6 +15,8 @@ #include "coop/interactables/atv_sync.h" #include "atv_spawn_park.h" +#include "coop/config/config.h" +#include "coop/config/config_registry.h" #include "coop/interactables/atv_corrector.h" #include "coop/dev/atv_eject_drill.h" #include "coop/interactables/atv_condition_sync.h" @@ -522,6 +524,15 @@ void OnAtvSpawn(const coop::net::AtvSpawnPayload& payload, uint8_t /*senderPeerS spawn_park::Park(synthKey, payload, "the receiving world is not ready"); return; } + // The ATV spawn drill's park arm: the first spawn is parked as if the world were not ready, so the park's + // hand-back is what spawns it. + static const bool s_parkFirst = coop::config::ResolveFlag(coop::config_registry::rows::atv_park_first_spawn); + static bool s_parkedFirst = false; + if (s_parkFirst && !s_parkedFirst) { + s_parkedFirst = true; + spawn_park::Park(synthKey, payload, "the drill parks the first spawn"); + return; + } if (!A::EnsureResolved()) { spawn_park::Park(synthKey, payload, "the ATV wrapper is not resolved"); return; } auto known = g_atvs.find(synthKey); if (known != g_atvs.end()) { @@ -550,6 +561,18 @@ void OnAtvSpawn(const coop::net::AtvSpawnPayload& payload, uint8_t /*senderPeerS synthKey.c_str(), className.c_str(), spawned, loc.X, loc.Y, loc.Z); } +uint32_t RuntimeAnnounced() { return g_synthCounter; } + +int RuntimeMirrors() { + int n = 0; + for (const auto& kv : g_atvs) + if (kv.second.isClientSpawnedMirror && R::IsLiveByIndex(kv.second.actor, kv.second.idx)) ++n; + return n; +} + +uint32_t ParkedEver() { return spawn_park::ParkedEver(); } +size_t ParkPending() { return spawn_park::Pending(); } + void OnAtvDestroy(const coop::net::AtvDestroyPayload& payload, uint8_t /*senderPeerSlot*/) { auto* s = g_session.load(std::memory_order_acquire); if (!s || s->role() == coop::net::Role::Host) return; // client-only From 24bf399122241c2baea549a867937baf506814c0 Mon Sep 17 00:00:00 2001 From: pelmentor Date: Sat, 10 Oct 2026 12:42:08 +0600 Subject: [PATCH 071/306] [dev] a throw drill proves a thrown container keeps its contents L7's transfer had no drill. throw_drill=run has the host spawn a backpack before the client's puppet and fill it with two drives by addObject once its inventory binds its slot; the client, once the mirror holds them and lies out of the fall stream, holds and throws it, and the host's DONE needs its new copy's digest (hashed without an eid) equal to the fixture's. With container_birth_skip_slice on the client, red fails with the copy empty and the transfer expired, and gone (with the rig's observer keeping the session alive) kicks the thrower and passes when the transfer ended with it through the peer-gone path. Co-Authored-By: Claude Opus 5.5 --- .../coop/config/config_registry_rows_dev.inc | 7 + src/votv-coop/include/coop/dev/throw_drill.h | 28 ++ .../include/coop/props/container_birth.h | 5 + src/votv-coop/sources.cmake | 1 + src/votv-coop/src/coop/dev/dev_lanes.cpp | 3 + src/votv-coop/src/coop/dev/throw_drill.cpp | 342 ++++++++++++++++++ .../src/coop/props/container_birth.cpp | 21 +- 7 files changed, 406 insertions(+), 1 deletion(-) create mode 100644 src/votv-coop/include/coop/dev/throw_drill.h create mode 100644 src/votv-coop/src/coop/dev/throw_drill.cpp diff --git a/src/votv-coop/include/coop/config/config_registry_rows_dev.inc b/src/votv-coop/include/coop/config/config_registry_rows_dev.inc index f7e5f8132..31ef699f2 100644 --- a/src/votv-coop/include/coop/config/config_registry_rows_dev.inc +++ b/src/votv-coop/include/coop/config/config_registry_rows_dev.inc @@ -469,6 +469,13 @@ CFG_FLAG(physmods_drill, "physmods_drill", "dev", false, "VOTVCOOP_PHYSMODS_DRIL "line says whether its array holds that one module alone. A joiner whose desk already holds modules at " "its world-ready (a rejoin into the drill's world) plugs nothing and says what it loaded. Lines tagged " "[PHYSMODS-DRILL].") +CFG_STRING(throw_drill, "throw_drill", "dev", "", "VOTVCOOP_THROW_DRILL", false, + "Drill (BOTH peers): a container a client throws keeps its contents on the host. run -- the host fills a backpack " + "before the client's puppet, the client throws it, the host's copy must match; red and gone -- with " + "container_birth_skip_slice=1 on the client, the copy stays empty (red) or the host kicks the thrower (gone). " + "The host's DONE line ends it.") +CFG_FLAG(container_birth_skip_slice, "container_birth_skip_slice", "dev", false, "VOTVCOOP_CONTAINER_BIRTH_SKIP_SLICE", + "Developer: a container this client throws sends no contents slice (the throw drill's red and gone).") CFG_STRING(atv_spawn_drill, "atv_spawn_drill", "dev", "", "VOTVCOOP_ATV_SPAWN_DRILL", false, "Drill (BOTH peers): a runtime ATV spawned on the host reaches each client once. run -- spawned once the client's " "world is ready; join -- while it is still loading (the world-ready replay); park -- as run, with " diff --git a/src/votv-coop/include/coop/dev/throw_drill.h b/src/votv-coop/include/coop/dev/throw_drill.h new file mode 100644 index 000000000..4d0fd556d --- /dev/null +++ b/src/votv-coop/include/coop/dev/throw_drill.h @@ -0,0 +1,28 @@ +// coop/dev/throw_drill.h -- [dev] a container a client throws keeps its contents on the host (L7). +// HOST -- once slot 1's world is ready and its puppet stands, spawns a backpack a metre before the puppet, puts two +// drives into it with the game's own addObject and takes the contents' digest; then finds its copy of +// the backpack the client's throw births. run: DONE when the copy's digest equals the fixture's. red +// (the client skips its slice): FAIL when the copy is still empty past the transfer's 30 s, naming +// whether the transfer expired. gone: once the copy awaits slot 1's slice, kicks slot 1 and is DONE when +// the transfer ended with its author. +// CLIENT -- once its world is ready, finds the backpack's mirror holding the host's two records, waits for it to +// lie still, takes it into the hand (Hold Object) and throws it (throwHoldingProp), the game's own birth. +// red and gone run with container_birth_skip_slice=1 on the client, and gone with the rig's --observer: a second +// client keeps the host's session alive, so the kick ends the transfer through the peer-gone path rather than with +// the whole session (with one client the kick is the session's end). "[THROW-DRILL] FAIL" is the lane failing +// (--fail-marker), "[THROW-DRILL] ABANDONED" the drill unable to do its part (--dead-marker). Run with +// throw_drill=run|red|gone and --done-marker "[THROW-DRILL] host DONE". + +#pragma once + +namespace coop::net { class Session; } + +namespace coop::dev::throw_drill { + +// Advances this peer's steps; a latched read when off. Game thread. +void Tick(coop::net::Session* s); + +// The session ended: the drill starts over. +void OnDisconnect(); + +} // namespace coop::dev::throw_drill diff --git a/src/votv-coop/include/coop/props/container_birth.h b/src/votv-coop/include/coop/props/container_birth.h index 1e9aab1cb..3d28f7eb8 100644 --- a/src/votv-coop/include/coop/props/container_birth.h +++ b/src/votv-coop/include/coop/props/container_birth.h @@ -38,6 +38,11 @@ void NoteHostBirth(void* actor); // HOST: the transfers this slot authored end with it. void OnPeerGone(uint8_t slot); +// HOST: transfers ended this process because their author left, and because their slice never came +// (the selftest's quiet runs not counted). For the throw drill. Game thread. +uint32_t EndedGone(); +uint32_t EndedExpired(); + // HOST: whether `actor` is an awaited transfer, from this author or (authorSlot 0) from any. bool Awaited(void* actor, uint8_t authorSlot); // HOST: the author's slice landed; the transfer ends. diff --git a/src/votv-coop/sources.cmake b/src/votv-coop/sources.cmake index 8da7b96cd..75804800e 100644 --- a/src/votv-coop/sources.cmake +++ b/src/votv-coop/sources.cmake @@ -543,6 +543,7 @@ set(VOTVCOOP_SOURCES src/coop/dev/sack_drill.cpp src/coop/dev/fall_drill.cpp src/coop/dev/atv_spawn_drill.cpp + src/coop/dev/throw_drill.cpp src/coop/dev/server_upgrade_drill.cpp src/coop/dev/server_drill.cpp src/coop/dev/sat_console_drill.cpp diff --git a/src/votv-coop/src/coop/dev/dev_lanes.cpp b/src/votv-coop/src/coop/dev/dev_lanes.cpp index de18319db..cf0a9f127 100644 --- a/src/votv-coop/src/coop/dev/dev_lanes.cpp +++ b/src/votv-coop/src/coop/dev/dev_lanes.cpp @@ -18,6 +18,7 @@ #include "coop/dev/drive_selftest.h" // [dev] rack-lane e2e circles #include "coop/dev/drone_call_drill.h" #include "coop/dev/atv_spawn_drill.h" // [dev] a runtime ATV reaches each client once +#include "coop/dev/throw_drill.h" // [dev] a thrown container keeps its contents on the host #include "coop/dev/fall_drill.h" // [dev] a falling host birth lands on the client where it lands on the host #include "coop/dev/sack_drill.h" // [dev] a client's point sack is paid by the host, once // [dev] a client's console press flies the host's drone #include "coop/dev/server_upgrade_drill.h" // [dev] a client's install and take-out at a server box @@ -140,6 +141,7 @@ void EndSession() { coop::dev::keypad_drill::OnDisconnect(); // [dev] the keypad and the legs belong to one world coop::dev::container_view_drill::OnDisconnect(); // [dev] the ATV, the view and the walks belong to one world coop::dev::physmods_drill::OnDisconnect(); // [dev] the client arms again, so a rejoin says its line + coop::dev::throw_drill::OnDisconnect(); // [dev] the backpack and its copy belong to one session coop::dev::atv_spawn_drill::OnDisconnect(); // [dev] the spawn belongs to one session coop::dev::fall_drill::OnDisconnect(); // [dev] the drop belongs to one session coop::dev::sack_drill::OnDisconnect(); // [dev] the sack and the counts belong to one session @@ -225,6 +227,7 @@ void TickDrills(coop::net::Session& session) { coop::dev::container_opener_probe::Tick(&session); // [dev] far containers' openers and the reach to them (latched read when off) coop::dev::container_view_drill::Tick(&session); // [dev] the view-close drill (a single bool read when off) coop::dev::physmods_drill::Tick(&session); // [dev] the module-plug drill (a single bool read when off) + coop::dev::throw_drill::Tick(&session); // [dev] the throw drill (a latched string compare when off) coop::dev::atv_spawn_drill::Tick(&session); // [dev] the ATV spawn drill (a latched string compare when off) coop::dev::fall_drill::Tick(&session); // [dev] the fall drill (a latched string compare when off) coop::dev::sack_drill::Tick(&session); // [dev] the point sack drill (a latched string compare when off) diff --git a/src/votv-coop/src/coop/dev/throw_drill.cpp b/src/votv-coop/src/coop/dev/throw_drill.cpp new file mode 100644 index 000000000..dd489aab4 --- /dev/null +++ b/src/votv-coop/src/coop/dev/throw_drill.cpp @@ -0,0 +1,342 @@ +// coop/dev/throw_drill.cpp -- see coop/dev/throw_drill.h. + +#include "coop/dev/throw_drill.h" + +#include "drive_drill_verbs.h" // Hold, Throw: the player's own pickup and throw (co-located private header) + +#include "coop/config/config.h" +#include "coop/config/config_registry.h" +#include "coop/net/end_reason.h" +#include "coop/net/session.h" +#include "coop/player/players_registry.h" +#include "coop/player/puppet_drive.h" +#include "coop/player/remote_player.h" +#include "coop/props/container_birth.h" +#include "coop/props/container_slice_wire.h" +#include "coop/props/prop_drive_stream.h" +#include "coop/session/join_progress.h" +#include "coop/session/net_pump.h" // HasAnnouncedWorldReady +#include "ue_wrap/actors/container_inventory.h" +#include "ue_wrap/core/asset_load.h" +#include "ue_wrap/core/cached_obj_ref.h" +#include "ue_wrap/core/call.h" +#include "ue_wrap/core/log.h" +#include "ue_wrap/core/object_index.h" +#include "ue_wrap/core/reflection.h" +#include "ue_wrap/engine/engine.h" + +#include + +#include +#include +#include +#include + +namespace coop::dev::throw_drill { +namespace { + +namespace E = ue_wrap::engine; +namespace R = ue_wrap::reflection; +namespace ci = ue_wrap::container_inventory; +namespace cw = coop::props::container_slice_wire; +namespace cb = coop::props::container_birth; + +constexpr const wchar_t* kPackPath = L"/Game/objects/prop_backpack.prop_backpack_C"; +constexpr const wchar_t* kPackClass = L"prop_backpack_C"; +constexpr const wchar_t* kItemClass = L"prop_drive_C"; +constexpr int kItems = 2; +constexpr float kAheadCm = 100.f; +constexpr float kDropCm = 30.f; +constexpr float kNearCm = 400.f; // CLIENT: the fixture's mirror is this near its player +constexpr uint64_t kFillBoundMs = 5000; // HOST: the spawn to the backpack's own inventory binding its slot +constexpr uint64_t kCopyBoundMs = 90000; // HOST: the fixture to its copy (the client's find, hold and throw) +constexpr uint64_t kSliceBoundMs = 35000; // HOST: the copy to its contents, past the transfer's 30 s +constexpr uint64_t kGoneBoundMs = 15000; // HOST: the kick to the transfer ending with its author +constexpr uint64_t kFindBoundMs = 60000; // CLIENT: world-ready to the fixture holding its records +constexpr uint64_t kSettleBoundMs = 10000; +constexpr uint64_t kCheckEveryMs = 250; +constexpr int kRestChecks = 4; + +enum class Step : uint8_t { Arm, Fill, Copy, Slice, Gone, Find, Settle, Throw, Done }; +Step g_step = Step::Arm; +uint64_t g_stepMs = 0; +uint64_t g_nextCheckMs = 0; +int g_session = 1; +ue_wrap::CachedObjRef g_fixture; // this peer's backpack before the throw +ue_wrap::CachedObjRef g_copy; // HOST: its copy the throw birthed +uint64_t g_digest = 0; // HOST: the fixture's contents, hashed without an eid +uint32_t g_expired0 = 0, g_gone0 = 0; +ue_wrap::FVector g_lastAt{}; +int g_restReads = 0; + +const std::string& Mode() { + static const std::string s = coop::config::ResolveString(::coop::config_registry::rows::throw_drill); + return s; +} +bool Enabled() { return Mode() == "run" || Mode() == "red" || Mode() == "gone"; } +void Next(Step s) { g_step = s; g_stepMs = ::GetTickCount64(); } +bool Expired(uint64_t bound) { return ::GetTickCount64() - g_stepMs > bound; } +bool Due() { + const uint64_t now = ::GetTickCount64(); + if (now < g_nextCheckMs) return false; + g_nextCheckMs = now + kCheckEveryMs; + return true; +} +void Fail(const char* what) { + UE_LOGW("[THROW-DRILL] FAIL in session %d: %s", g_session, what); + g_step = Step::Done; +} +void Abandon(const char* why) { + UE_LOGW("[THROW-DRILL] ABANDONED in session %d: %s", g_session, why); + g_step = Step::Done; +} + +// A container's records and their digest, hashed with no eid so a copy compares to its original. False unread. +bool Contents(void* container, size_t* count, uint64_t* digest) { + void* inv = container ? ci::InventoryOf(container) : nullptr; + std::vector recs; + if (!inv || !ci::ReadContents(inv, recs, cw::kMaxRecords)) return false; + *count = recs.size(); + *digest = cw::ContentHash(0, recs); + return true; +} + +// The inventory's own put-in, as the player's hand puts an item in. True when it took the item. +bool AddObject(void* container, void* item) { + void* inv = ci::InventoryOf(container); + void* fn = inv ? R::FindDispatchFunctionCached(R::ClassOf(inv), L"addObject") : nullptr; + ue_wrap::ParamFrame f(fn); + if (!fn || !f.valid() || !f.Set(L"actor", item) || !f.Set(L"insertIndex1", -1) || + !ue_wrap::Call(inv, f)) + return false; + return f.Get(L"return"); +} + +// HOST: the backpacks standing when the fixture was filled, held by slot and serial, so a copy born at a +// destroyed one's address still reads as new. +std::vector g_before; + +bool IsDefault(void* obj) { return R::NameStartsWith(R::NameOf(obj), L"Default__"); } + +void SnapshotPacks(void* cls) { + g_before.clear(); + ue_wrap::object_index::ForEachInstance(cls, [](void*, void* obj, int32_t) { + if (IsDefault(obj)) return; + g_before.emplace_back(); + g_before.back().Set(obj); + }, nullptr); +} + +// HOST: a backpack born since the snapshot that is a world container: the copy the throw births. +void* NewCopy(void* cls) { + void* found = nullptr; + ue_wrap::object_index::ForEachInstance(cls, [](void* c, void* obj, int32_t) { + auto* out = static_cast(c); + if (*out || IsDefault(obj) || !ci::IsContainer(obj)) return; + for (const ue_wrap::CachedObjRef& b : g_before) + if (b.Is(obj)) return; + void* inv = ci::InventoryOf(obj); + if (inv && ci::IsWorldInventory(inv)) *out = obj; + }, &found); + return found; +} + +// CLIENT: the backpack within kNearCm of `from` holding the host's records, the nearest of them. +struct Near { ue_wrap::FVector from; void* best; float bestCm; }; +void* NearestFixture(void* cls, const ue_wrap::FVector& from) { + Near ctx{from, nullptr, kNearCm}; + ue_wrap::object_index::ForEachInstance(cls, [](void* c, void* obj, int32_t) { + auto* n = static_cast(c); + ue_wrap::FVector at{}; + size_t count = 0; + uint64_t digest = 0; + if (!R::IsLive(obj) || !E::TryGetActorLocation(obj, at)) return; + const float cm = std::hypot(at.X - n->from.X, at.Y - n->from.Y); + if (cm < n->bestCm && Contents(obj, &count, &digest) && count == static_cast(kItems)) { + n->best = obj; + n->bestCm = cm; + } + }, &ctx); + return ctx.best; +} + +void HostTick(coop::net::Session* s) { + if (g_step == Step::Done || !Due()) return; + switch (g_step) { + case Step::Arm: { + // gone waits for the observer too: the kick must leave a peer in the session (the rig starts it after slot 1). + if (!s->IsSlotWorldReady(1) || (Mode() == "gone" && !s->IsSlotWorldReady(2))) return; + void* puppet = coop::puppet_drive::Puppet(1).GetActor(); + ue_wrap::FVector at{}; + if (!puppet || !R::IsLive(puppet) || !E::TryGetActorLocation(puppet, at)) return; + void* packCls = ue_wrap::asset_load::LoadObjectByPath(kPackPath); + if (!packCls) { Abandon("the backpack's class does not load"); return; } + const ue_wrap::FVector fwd = E::GetActorForwardVector(puppet); + const ue_wrap::FVector put{at.X + fwd.X * kAheadCm, at.Y + fwd.Y * kAheadCm, at.Z + kDropCm}; + void* pack = E::SpawnActor(packCls, put); + if (!pack || !ci::IsContainer(pack)) { Abandon("the backpack did not spawn as a container"); return; } + g_fixture.Set(pack); + Next(Step::Fill); + return; + } + case Step::Fill: { // its inventory's own init binds the save slot's contents first; addObject indexes it + void* pack = g_fixture.Get(); + void* inv = pack ? ci::InventoryOf(pack) : nullptr; + if (!pack) { Abandon("the backpack died"); return; } + if (!inv || !ci::ContentsSlot(inv)) { + if (Expired(kFillBoundMs)) Abandon("the backpack's inventory never bound its contents slot"); + return; + } + void* itemCls = R::FindClass(kItemClass); + ue_wrap::FVector put{}; + if (!itemCls || !E::TryGetActorLocation(pack, put)) { Abandon("the drive's class or the backpack's place is unread"); return; } + int added = 0; + for (int i = 0; i < kItems; ++i) { + void* item = E::SpawnActor(itemCls, {put.X, put.Y, put.Z + 60.f + 30.f * i}); + if (item && AddObject(pack, item)) ++added; + } + size_t n = 0; + if (added != kItems || !Contents(pack, &n, &g_digest) || n != static_cast(kItems)) { + Abandon("the backpack did not take the two drives"); + return; + } + g_expired0 = cb::EndedExpired(); + g_gone0 = cb::EndedGone(); + SnapshotPacks(ue_wrap::object_index::ClassByName(kPackClass)); + UE_LOGI("[THROW-DRILL] host (%s): a backpack holding %zu drives before the client's puppet, digest %016llx", + Mode().c_str(), n, static_cast(g_digest)); + Next(Step::Copy); + return; + } + case Step::Copy: { // the throw births a new backpack here, from the client's intent + void* copy = NewCopy(ue_wrap::object_index::ClassByName(kPackClass)); + if (!copy) { + if (Expired(kCopyBoundMs)) Fail("no copy of the thrown backpack was born on the host within 90 s"); + return; + } + g_copy.Set(copy); + UE_LOGI("[THROW-DRILL] host: the thrown backpack's copy is born here (awaiting slot 1's slice: %s)", + cb::Awaited(copy, 1) ? "yes" : "no"); + if (Mode() == "gone") { + if (!cb::Awaited(copy, 1)) { Abandon("the copy was not awaiting slot 1's slice to end with its author"); return; } + s->Kick(1, coop::net::EndReason::KickedByHost, "the throw drill ends the thrower's session"); + Next(Step::Gone); + return; + } + Next(Step::Slice); + return; + } + case Step::Slice: { + size_t n = 0; + uint64_t digest = 0; + void* copy = g_copy.Get(); + const bool read = copy && Contents(copy, &n, &digest); + if (read && digest == g_digest) { + if (Mode() == "red") { + Fail("the copy holds the thrower's contents though the client skipped its slice"); + return; + } + UE_LOGI("[THROW-DRILL] host DONE in session %d: the copy holds the thrower's %zu records, digest equal", g_session, n); + g_step = Step::Done; + return; + } + if (!Expired(kSliceBoundMs)) return; + char why[200]; + std::snprintf(why, sizeof(why), "the copy holds %zu record(s) against the thrower's %d (digest %s); its transfer " + "expired: %s", read ? n : 0, kItems, read ? "differs" : "unread", + cb::EndedExpired() > g_expired0 ? "yes" : "no"); + Fail(why); + return; + } + case Step::Gone: + if (cb::EndedGone() > g_gone0) { + UE_LOGI("[THROW-DRILL] host DONE in session %d: the kicked thrower's transfer ended with it", g_session); + g_step = Step::Done; + return; + } + if (Expired(kGoneBoundMs)) Fail("the kicked thrower's transfer did not end with it within 15 s"); + return; + default: + return; + } +} + +void ClientTick() { + if (g_step == Step::Done) return; + // The first client throws; a second, the rig's observer, keeps the host's session alive when gone kicks the + // first, so the transfer ends through the peer-gone path and not with the whole session. + if (coop::players::Registry::Get().LocalPeerId() > 1) return; + void* player = coop::players::Registry::Get().Local(); + if (!player) return; + switch (g_step) { + case Step::Arm: + if (!coop::net_pump::HasAnnouncedWorldReady() || + coop::join_progress::CurrentPhase() != coop::join_progress::Phase::Idle) + return; + Next(Step::Find); + return; + case Step::Find: { + if (!Due()) return; + ue_wrap::FVector me{}; + void* cls = ue_wrap::object_index::ClassByName(kPackClass); + void* pack = cls && E::TryGetActorLocation(player, me) ? NearestFixture(cls, me) : nullptr; + if (pack) { + g_fixture.Set(pack); + g_restReads = 0; + UE_LOGI("[THROW-DRILL] client: the backpack's mirror holds the host's %d records", kItems); + Next(Step::Settle); + return; + } + if (Expired(kFindBoundMs)) Abandon("no backpack holding the host's two records stood near this client within 60 s"); + return; + } + case Step::Settle: { + if (!Due()) return; + void* pack = g_fixture.Get(); + ue_wrap::FVector at{}; + if (!pack || !E::TryGetActorLocation(pack, at)) { Abandon("the backpack's mirror died"); return; } + g_restReads = std::fabs(at.X - g_lastAt.X) + std::fabs(at.Y - g_lastAt.Y) + std::fabs(at.Z - g_lastAt.Z) < 1.f + ? g_restReads + 1 : 0; + g_lastAt = at; + // At rest and out of the host's fall stream: a mirror the stream still parks is not the hand's to take. + if (g_restReads >= kRestChecks && !coop::prop_drive_stream::IsParked(pack)) Next(Step::Throw); + else if (Expired(kSettleBoundMs)) Abandon("the backpack's mirror never came to rest"); + return; + } + case Step::Throw: { + void* pack = g_fixture.Get(); + if (!pack) { Abandon("the backpack's mirror died"); return; } + if (!coop::dev::drive_drill_verbs::Hold(player, pack)) { Abandon("the backpack could not be taken into the hand"); return; } + if (!coop::dev::drive_drill_verbs::Throw(player)) { Abandon("the hand's backpack could not be thrown"); return; } + UE_LOGI("[THROW-DRILL] client (%s): the backpack taken into the hand and thrown, a new actor's birth", Mode().c_str()); + g_step = Step::Done; + return; + } + default: + return; + } +} + +} // namespace + +void Tick(coop::net::Session* s) { + if (!Enabled() || !s) return; + if (s->role() == coop::net::Role::Host) HostTick(s); + else if (s->connected()) ClientTick(); +} + +void OnDisconnect() { + if (!Enabled()) return; + ++g_session; + g_step = Step::Arm; + g_stepMs = g_nextCheckMs = 0; + g_fixture.Reset(); + g_copy.Reset(); + g_before.clear(); + g_digest = 0; + g_expired0 = g_gone0 = 0; + g_lastAt = {}; + g_restReads = 0; +} + +} // namespace coop::dev::throw_drill diff --git a/src/votv-coop/src/coop/props/container_birth.cpp b/src/votv-coop/src/coop/props/container_birth.cpp index 500e425dc..2b40a0e72 100644 --- a/src/votv-coop/src/coop/props/container_birth.cpp +++ b/src/votv-coop/src/coop/props/container_birth.cpp @@ -2,6 +2,9 @@ #include "coop/props/container_birth.h" +#include "coop/config/config.h" +#include "coop/config/config_registry.h" + #include "coop/element/registry.h" #include "coop/props/container_slice_wire.h" #include "coop/props/prop_element_tracker.h" // SessionIsHost @@ -69,8 +72,18 @@ void Push(std::vector& births, void* actor, uint8_t authorSlot) { } // namespace +uint32_t g_endedGone = 0, g_endedExpired = 0; + void NoteAuthoredBirth(void* actor) { if (IsHost() || !ci::IsContainer(actor)) return; + // The throw drill's red and gone: the slice is never sent, so the host's copy stays empty and its + // transfer waits for a slice that does not come. + static const bool s_skip = coop::config::ResolveFlag(coop::config_registry::rows::container_birth_skip_slice); + if (s_skip) { + UE_LOGW("container_birth: DRILL -- CLIENT threw container %p and sends no slice " + "(dev.container_birth_skip_slice)", actor); + return; + } void* inv = ci::InventoryOf(actor); if (!inv || !ci::IsWorldInventory(inv)) { UE_LOGW("container_birth: CLIENT threw container %p, but its inventory %s -- its contents stay on " @@ -105,13 +118,18 @@ void OnPeerGone(uint8_t slot) { // A transfer is bound to the peer that threw: a later occupant of the slot does not complete it. for (size_t i = 0; i < g_awaited.size();) { if (g_awaited[i].authorSlot != slot) { ++i; continue; } - if (!g_quiet) + if (!g_quiet) { + ++g_endedGone; UE_LOGW("container_birth: slot %u left before the contents of a container it threw came", static_cast(slot)); + } g_awaited.erase(g_awaited.begin() + static_cast(i)); } } +uint32_t EndedGone() { return g_endedGone; } +uint32_t EndedExpired() { return g_endedExpired; } + // The host never publishes an awaited container, so nothing a slice could have been based on exists. bool Awaited(void* actor, uint8_t authorSlot) { if (g_awaited.empty() || !actor) return false; @@ -148,6 +166,7 @@ void Sweep(uint64_t nowMs, std::set& dirty) { const Birth& b = g_awaited[i]; const bool live = b.actor.Alive(); if (live && nowMs < b.deadlineMs) { ++i; continue; } + if (live && !g_quiet) ++g_endedExpired; if (live && !g_quiet) UE_LOGW("container_birth: the contents of a container slot %u threw never came -- the host's " "copy stays as it built it, and the thrower's own contents are refused from now on and " From aa3b05429dd284a0428992df522e4533aef45d97 Mon Sep 17 00:00:00 2001 From: pelmentor Date: Sat, 10 Oct 2026 13:15:17 +0600 Subject: [PATCH 072/306] [dev] a log drill proves two processes of one install keep their logs L18's per-process log had no drill: the second process of one install falls back to multivoid..log when the usual file is held, and nothing checked it. Every log's third line now names its process ("process "), and log::BasePath gives the usual path. With log_drill=1 a process writing the usual name says so at boot; one that found it held passes when the usual file names another process and its own file carries its PID. Co-Authored-By: Claude Opus 5.5 --- .../coop/config/config_registry_rows_dev.inc | 3 + src/votv-coop/include/coop/dev/log_drill.h | 14 +++++ src/votv-coop/include/ue_wrap/core/log.h | 4 ++ src/votv-coop/sources.cmake | 1 + src/votv-coop/src/coop/dev/log_drill.cpp | 59 +++++++++++++++++++ src/votv-coop/src/harness/harness.cpp | 3 + src/votv-coop/src/ue_wrap/core/log.cpp | 7 +++ 7 files changed, 91 insertions(+) create mode 100644 src/votv-coop/include/coop/dev/log_drill.h create mode 100644 src/votv-coop/src/coop/dev/log_drill.cpp diff --git a/src/votv-coop/include/coop/config/config_registry_rows_dev.inc b/src/votv-coop/include/coop/config/config_registry_rows_dev.inc index 31ef699f2..df9bd1aab 100644 --- a/src/votv-coop/include/coop/config/config_registry_rows_dev.inc +++ b/src/votv-coop/include/coop/config/config_registry_rows_dev.inc @@ -469,6 +469,9 @@ CFG_FLAG(physmods_drill, "physmods_drill", "dev", false, "VOTVCOOP_PHYSMODS_DRIL "line says whether its array holds that one module alone. A joiner whose desk already holds modules at " "its world-ready (a rejoin into the drill's world) plugs nothing and says what it loaded. Lines tagged " "[PHYSMODS-DRILL].") +CFG_FLAG(log_drill, "log_drill", "dev", false, "VOTVCOOP_LOG_DRILL", + "Drill (one install, two processes): at boot each process says whose log it writes; one that found the usual " + "log held passes when that log names another process. Run with mp.py twolog.") CFG_STRING(throw_drill, "throw_drill", "dev", "", "VOTVCOOP_THROW_DRILL", false, "Drill (BOTH peers): a container a client throws keeps its contents on the host. run -- the host fills a backpack " "before the client's puppet, the client throws it, the host's copy must match; red and gone -- with " diff --git a/src/votv-coop/include/coop/dev/log_drill.h b/src/votv-coop/include/coop/dev/log_drill.h new file mode 100644 index 000000000..2dd538bb1 --- /dev/null +++ b/src/votv-coop/include/coop/dev/log_drill.h @@ -0,0 +1,14 @@ +// coop/dev/log_drill.h -- [dev] two processes of one install keep separate logs (L18). Once at boot, with +// log_drill=1: a process writing the install's usual log name says so; one that found it held writes its own +// multivoid..log, reads the usual file's process line and passes when that names another process, its own file +// carrying its PID. Run with mp.py twolog, which starts the second process from the host's own folder; +// "[LOG-DRILL] DONE" is the pass, "[LOG-DRILL] FAIL" the lane failing. + +#pragma once + +namespace coop::dev::log_drill { + +// The one check, at boot; a latched read when off. Reads files only, no engine. Any thread. +void RunOnce(); + +} // namespace coop::dev::log_drill diff --git a/src/votv-coop/include/ue_wrap/core/log.h b/src/votv-coop/include/ue_wrap/core/log.h index ef23c2cd5..ab3e44c2d 100644 --- a/src/votv-coop/include/ue_wrap/core/log.h +++ b/src/votv-coop/include/ue_wrap/core/log.h @@ -53,6 +53,10 @@ void Flush(); // `multivoid.log`: it asks here. Opens the log first, so the answer is the real one. Any thread. std::wstring CurrentPath(); std::wstring PreviousPath(); +// The path a process of this install writes while no other holds it: the exe directory's +// multivoid.log, or the VOTVCOOP_LOG name. The live log's third line names the process that writes +// it ("process "), so an overlapped launch can tell whose file the usual name is. Any thread. +std::wstring BasePath(); // The address mark. A log line that prints a peer's address, a typed dial text, or an endpoint // that is not the project's own wraps the value in Addr(): it writes kAddrOpen + value + diff --git a/src/votv-coop/sources.cmake b/src/votv-coop/sources.cmake index 75804800e..5fce4c2c0 100644 --- a/src/votv-coop/sources.cmake +++ b/src/votv-coop/sources.cmake @@ -544,6 +544,7 @@ set(VOTVCOOP_SOURCES src/coop/dev/fall_drill.cpp src/coop/dev/atv_spawn_drill.cpp src/coop/dev/throw_drill.cpp + src/coop/dev/log_drill.cpp src/coop/dev/server_upgrade_drill.cpp src/coop/dev/server_drill.cpp src/coop/dev/sat_console_drill.cpp diff --git a/src/votv-coop/src/coop/dev/log_drill.cpp b/src/votv-coop/src/coop/dev/log_drill.cpp new file mode 100644 index 000000000..529f30374 --- /dev/null +++ b/src/votv-coop/src/coop/dev/log_drill.cpp @@ -0,0 +1,59 @@ +// coop/dev/log_drill.cpp -- see coop/dev/log_drill.h. + +#include "coop/dev/log_drill.h" + +#include "coop/config/config.h" +#include "coop/config/config_registry.h" +#include "ue_wrap/core/log.h" + +#include + +#include +#include +#include + +namespace coop::dev::log_drill { +namespace { + +// The usual file's process line ("process ", line 3 of every log), read while its writer holds it: the +// writer shares reads, so a reader asks for write sharing too. 0 when it does not read. +unsigned long LiveWriter(const std::wstring& path) { + HANDLE h = ::CreateFileW(path.c_str(), GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr); + if (h == INVALID_HANDLE_VALUE) return 0; + char buf[512] = {}; + DWORD got = 0; + const BOOL ok = ::ReadFile(h, buf, sizeof(buf) - 1, &got, nullptr); + ::CloseHandle(h); + if (!ok) return 0; + buf[got] = '\0'; + const char* line = std::strstr(buf, "\nprocess "); + return line ? std::strtoul(line + 9, nullptr, 10) : 0; +} + +} // namespace + +void RunOnce() { + static const bool on = coop::config::ResolveFlag(::coop::config_registry::rows::log_drill); + if (!on) return; + const unsigned long me = ::GetCurrentProcessId(); + const std::wstring mine = ue_wrap::log::CurrentPath(); + const std::wstring base = ue_wrap::log::BasePath(); + if (_wcsicmp(mine.c_str(), base.c_str()) == 0) { + UE_LOGI("[LOG-DRILL] process %lu writes the install's usual log '%ls'", me, mine.c_str()); + return; + } + std::wstring expected = base; + if (expected.size() > 4 && _wcsicmp(expected.c_str() + expected.size() - 4, L".log") == 0) expected.resize(expected.size() - 4); + expected += L"." + std::to_wstring(me) + L".log"; + const unsigned long other = LiveWriter(base); + if (_wcsicmp(mine.c_str(), expected.c_str()) != 0 || other == 0 || other == me) { + UE_LOGW("[LOG-DRILL] FAIL: process %lu writes '%ls' (expected '%ls'); the usual log names process %lu", + me, mine.c_str(), expected.c_str(), other); + return; + } + UE_LOGI("[LOG-DRILL] DONE: process %lu writes its own '%ls'; the usual log is process %lu's -- PASS", me, + mine.c_str(), other); +} + +} // namespace coop::dev::log_drill diff --git a/src/votv-coop/src/harness/harness.cpp b/src/votv-coop/src/harness/harness.cpp index b252f6abe..a5407da17 100644 --- a/src/votv-coop/src/harness/harness.cpp +++ b/src/votv-coop/src/harness/harness.cpp @@ -19,6 +19,7 @@ #include "harness/mod_environment.h" #include "harness/sdk_check.h" #include "coop/dev/freecam.h" +#include "coop/dev/log_drill.h" // [dev] the two-process log drill #include "coop/input/input_owner.h" #include "coop/dev/object_overlay.h" #include "coop/dev/ragdoll_bone_overlay.h" @@ -171,6 +172,8 @@ DWORD WINAPI TimelineThread(LPVOID param) { // finds ready is: the master list, the nickname seed and the identity above. browser_click_arm::FireFromEnv(); + // [dev] two processes of one install keep separate logs (log_drill=1): files only, on every boot path. + coop::dev::log_drill::RunOnce(); const bool storyBoot = (scenario == "play"); const bool menuMode = (scenario == "menu"); if (storyBoot) { diff --git a/src/votv-coop/src/ue_wrap/core/log.cpp b/src/votv-coop/src/ue_wrap/core/log.cpp index 4de519237..7a3a21148 100644 --- a/src/votv-coop/src/ue_wrap/core/log.cpp +++ b/src/votv-coop/src/ue_wrap/core/log.cpp @@ -135,6 +135,7 @@ void EnsureOpen() { if (g_file) { std::fprintf(g_file, "==== Multivoid log ====\n"); std::fprintf(g_file, "%s\n", kLogFormatLine); + std::fprintf(g_file, "process %lu\n", static_cast(::GetCurrentProcessId())); } g_opened = true; } @@ -173,6 +174,12 @@ std::wstring CurrentPath() { return copy; } +std::wstring BasePath() { + wchar_t path[MAX_PATH] = {}; + LogPath(path); + return path; +} + std::wstring PreviousPath() { EnsureOpen(); wchar_t copy[MAX_PATH]; From abb5dca5cac5cee2545a0c1937b57f528e74f11a Mon Sep 17 00:00:00 2001 From: pelmentor Date: Sat, 10 Oct 2026 13:36:21 +0600 Subject: [PATCH 073/306] [dev] drills hold bodies by serial and judge a settled look and end dmghazard holds the puppet and the local player by slot and serial across its game-thread tasks. selectedAction frees the name copy its names-list path returns, and its brief says the look took the actor. The sack drill takes the nearest sack within 4 m, checks the hit before the look and again at the press, and bounds its balance reads. prop_drive_stream keeps its last eight ends, cleared with the session; the fall drill takes the newest drive end since the session's first tick. The ATV drill passes once one mirror held a second with nothing parked, and its join arm abandons a missed window. container_birth's drill counters stay file-local. Co-Authored-By: Claude Opus 5.5 --- .../coop/config/config_registry_rows_dev.inc | 4 +- .../include/coop/dev/atv_spawn_drill.h | 7 +- src/votv-coop/include/coop/dev/fall_drill.h | 6 +- src/votv-coop/include/coop/dev/sack_drill.h | 5 +- .../include/coop/props/prop_drive_stream.h | 13 +++- src/votv-coop/include/harness/autotest.h | 2 +- .../ue_wrap/engine/engine_mainplayer.h | 5 +- .../src/coop/dev/atv_spawn_drill.cpp | 25 ++++--- src/votv-coop/src/coop/dev/dev_lanes.cpp | 12 ++-- .../src/coop/dev/drive_drill_verbs.h | 2 +- src/votv-coop/src/coop/dev/fall_drill.cpp | 27 ++++--- src/votv-coop/src/coop/dev/sack_drill.cpp | 50 ++++++++++--- src/votv-coop/src/coop/dev/throw_drill.cpp | 1 + .../src/coop/props/container_birth.cpp | 4 +- .../src/coop/props/prop_drive_stream.cpp | 27 ++++--- .../harness/autotest/autotest_dmghazard.cpp | 70 +++++++++---------- .../src/ue_wrap/engine/engine_mainplayer.cpp | 9 ++- 17 files changed, 162 insertions(+), 107 deletions(-) diff --git a/src/votv-coop/include/coop/config/config_registry_rows_dev.inc b/src/votv-coop/include/coop/config/config_registry_rows_dev.inc index df9bd1aab..0b9e413a0 100644 --- a/src/votv-coop/include/coop/config/config_registry_rows_dev.inc +++ b/src/votv-coop/include/coop/config/config_registry_rows_dev.inc @@ -484,8 +484,8 @@ CFG_STRING(atv_spawn_drill, "atv_spawn_drill", "dev", "", "VOTVCOOP_ATV_SPAWN_DR "world is ready; join -- while it is still loading (the world-ready replay); park -- as run, with " "atv_park_first_spawn=1 on the client. The client's DONE line ends it.") CFG_FLAG(atv_park_first_spawn, "atv_park_first_spawn", "dev", false, "VOTVCOOP_ATV_PARK_FIRST_SPAWN", - "Developer: the first runtime ATV spawn this client receives is parked as if its world were not ready (the ATV " - "spawn drill's park arm).") + "Developer: the first runtime ATV spawn that reaches this client's ready world is parked once, process-wide, as " + "if the world were not (the ATV spawn drill's park arm).") CFG_STRING(fall_drill, "fall_drill", "dev", "", "VOTVCOOP_FALL_DRILL", false, "Drill (BOTH peers): a host birth that falls lands on the client where it lands on the host. run -- the host " "drops a drive from 4 m once the client's world is ready; the client's DONE line ends it.") diff --git a/src/votv-coop/include/coop/dev/atv_spawn_drill.h b/src/votv-coop/include/coop/dev/atv_spawn_drill.h index 9e776c34d..9226921e2 100644 --- a/src/votv-coop/include/coop/dev/atv_spawn_drill.h +++ b/src/votv-coop/include/coop/dev/atv_spawn_drill.h @@ -2,9 +2,10 @@ // HOST -- spawns an ATV_C in front of its own player: run, once slot 1's world is ready (the spawn's broadcast); // join, once slot 1 is seated and still loading (the world-ready replay of every runtime ATV); park, as // run. It says when its ATV lane announced the ATV under a synthetic key. -// CLIENT -- once its world is ready and no runtime spawn waits in the park, passes when exactly one runtime ATV -// mirror stands here; in park mode atv_park_first_spawn=1 parks the first spawn as if the world were not -// ready, and the pass also needs the park to have held it and handed it back. +// CLIENT -- once its world is ready, passes when exactly one runtime ATV mirror stands here with nothing parked, +// held for a second; run covers either delivery (the broadcast, or the park's hand-back when it lands in +// a world not yet ready). In park mode atv_park_first_spawn=1 parks the first spawn that reaches a ready +// world, and the pass also needs the park to have held a spawn this session. // "[ATV-SPAWN-DRILL] FAIL" is the lane failing (--fail-marker), "[ATV-SPAWN-DRILL] ABANDONED" the drill unable to do // its part (--dead-marker). Run with atv_spawn_drill=run|join|park and --done-marker "[ATV-SPAWN-DRILL] client DONE". diff --git a/src/votv-coop/include/coop/dev/fall_drill.h b/src/votv-coop/include/coop/dev/fall_drill.h index ead6f9734..a64f30459 100644 --- a/src/votv-coop/include/coop/dev/fall_drill.h +++ b/src/votv-coop/include/coop/dev/fall_drill.h @@ -1,8 +1,8 @@ // coop/dev/fall_drill.h -- [dev] a host birth that falls lands on the client where it lands on the host (L11). -// HOST -- once slot 1's world is ready and its puppet stands, spawns a drive 4 m up in front of its own player and +// HOST -- once slot 1's world is ready and its puppet stands, spawns a drive 2 m ahead and 4 m up of its own player and // says, once the drive is tracked and lies still, where it came to rest and how far it fell. -// CLIENT -- once its world is ready, waits for the first stream end the host applies here after that, and passes -// when its copy of that prop rests within 5 cm of the host's final pose the end carried. +// CLIENT -- once its world is ready, takes the newest stream end applied here this session whose prop is a drive, +// and passes when its copy rests within 5 cm of the host's final pose the end carried. // The red: prop_fall_no_coast=1 on the host streams no fall, so no end comes and the client fails. "[FALL-DRILL] // FAIL" is the lane failing (--fail-marker), "[FALL-DRILL] ABANDONED" the drill unable to do its part (--dead-marker). // Run with fall_drill=run and --done-marker "[FALL-DRILL] client DONE". diff --git a/src/votv-coop/include/coop/dev/sack_drill.h b/src/votv-coop/include/coop/dev/sack_drill.h index a86c0dc40..0a2a574bb 100644 --- a/src/votv-coop/include/coop/dev/sack_drill.h +++ b/src/votv-coop/include/coop/dev/sack_drill.h @@ -2,8 +2,9 @@ // HOST -- once slot 1's world is ready and its puppet stands, spawns a prop_pointSack_C a metre in front of the // puppet and reads its own balance and the sack's points; DONE when its balance rose by exactly those // points with one paid redemption, FAIL when no pay came within 60 s or the balance moved by another sum. -// CLIENT -- once its world is ready, finds the sack's mirror, turns its own interaction trace onto it with the aim -// fan, presses useSelectedAction (the player's E) and says when its mirrored balance rose. +// CLIENT -- once its world is ready, finds the nearest sack mirror within 4 m and waits for it to rest, turns its own +// interaction trace onto it with the aim fan, waits for its look to settle on it, presses +// useSelectedAction (the player's E) and says when its mirrored balance rose. // The red: point_sack_client_runs=1 on the client lets the client's own body run, so the host is never paid and its // FAIL ends the run. "[SACK-DRILL] FAIL" is the lane failing (--fail-marker), "[SACK-DRILL] ABANDONED" the drill unable // to do its part (--dead-marker). Run with sack_drill=run and --done-marker "[SACK-DRILL] host DONE". diff --git a/src/votv-coop/include/coop/props/prop_drive_stream.h b/src/votv-coop/include/coop/props/prop_drive_stream.h index 1cf4fd6e8..f2e91dad4 100644 --- a/src/votv-coop/include/coop/props/prop_drive_stream.h +++ b/src/votv-coop/include/coop/props/prop_drive_stream.h @@ -15,6 +15,7 @@ #include "ue_wrap/core/types.h" +#include #include namespace coop::net { @@ -38,8 +39,14 @@ bool IsParked(void* actor); // Session end: every driven prop gets its physics back. Game thread. void OnDisconnect(); -// The last stream end applied here: its eid, the host's final pose and the tick it landed -// (GetTickCount64). False before any. For the fall drill's verdict. Game thread. -bool LastAppliedEnd(uint32_t* eid, ue_wrap::FVector* pose, uint64_t* atMs); +// The stream ends applied here lately, newest first, at most `max` of the last eight: each eid, the +// host's final pose and the tick it landed (GetTickCount64). Cleared with the session. For the fall +// drill's verdict. Game thread. +struct AppliedEnd { + uint32_t eid = 0; + ue_wrap::FVector pose{}; + uint64_t atMs = 0; +}; +size_t RecentEnds(AppliedEnd* out, size_t max); } // namespace coop::prop_drive_stream diff --git a/src/votv-coop/include/harness/autotest.h b/src/votv-coop/include/harness/autotest.h index 42f4d0818..3410fe530 100644 --- a/src/votv-coop/include/harness/autotest.h +++ b/src/votv-coop/include/harness/autotest.h @@ -17,7 +17,7 @@ namespace harness::autotest { // since routines poll from worker threads. bool IsClientRole(); -// The host routines' one readiness wait: slot `slot` is seated and its world is ready, the peer's +// A host routine's readiness wait: slot `slot` is seated and its world is ready, the peer's // own milestone (what its rig: READY peer-world-ready line reports), polled every 50 ms for at most // `budgetMs`. False when the budget ran out; the caller names its phase. Any thread. bool WaitPeerWorldReady(int slot, DWORD budgetMs); diff --git a/src/votv-coop/include/ue_wrap/engine/engine_mainplayer.h b/src/votv-coop/include/ue_wrap/engine/engine_mainplayer.h index 94248b748..39a421c87 100644 --- a/src/votv-coop/include/ue_wrap/engine/engine_mainplayer.h +++ b/src/votv-coop/include/ue_wrap/engine/engine_mainplayer.h @@ -73,8 +73,9 @@ bool CallMainPlayerUpdateHold(void* mainPlayer); bool CallMainPlayerUseSelectedAction(void* mainPlayer); // Whether useSelectedAction would act now: the player's own selectedAction, true once its look-at actor -// answers lookAt and the action list built for it holds an action. The list is rebuilt after the look -// changes, so a press on the tick the trace lands finds none and the game denies it. Game thread. +// answers lookAt and the player's look flag holds -- the look took that actor, which the game settles +// on a later tick than the trace landing, so a press on the tick the trace lands is denied. It does not +// say which actor the look took: the caller compares the hit. Game thread. bool MainPlayerHasSelectedAction(void* mainPlayer); // The player's own pickup of `target`: `Hold Object` with `manual` set, which it takes before whatever the diff --git a/src/votv-coop/src/coop/dev/atv_spawn_drill.cpp b/src/votv-coop/src/coop/dev/atv_spawn_drill.cpp index 6350fa886..9db34f136 100644 --- a/src/votv-coop/src/coop/dev/atv_spawn_drill.cpp +++ b/src/votv-coop/src/coop/dev/atv_spawn_drill.cpp @@ -16,6 +16,7 @@ #include #include +#include #include namespace coop::dev::atv_spawn_drill { @@ -28,6 +29,7 @@ constexpr float kLiftCm = 50.f; constexpr uint64_t kAnnounceBound = 30000; // HOST: the spawn to the lane's announce constexpr uint64_t kMirrorBound = 60000; // CLIENT: world-ready to the mirror constexpr uint64_t kCheckEveryMs = 250; +constexpr int kOnceChecks = 4; // CLIENT: polls in a row the count must hold at one, a second enum class Step : uint8_t { Arm, Announce, Mirror, Done }; Step g_step = Step::Arm; @@ -36,6 +38,7 @@ uint64_t g_nextCheckMs = 0; int g_session = 1; uint32_t g_announced0 = 0; // HOST: runtime ATVs announced before the drill's uint32_t g_parked0 = 0; // CLIENT: spawns parked before this session's first tick +int g_onceReads = 0; // CLIENT: polls in a row with exactly one mirror and nothing parked bool g_baselined = false; const std::string& Mode() { @@ -63,8 +66,10 @@ bool Due() { void HostTick(coop::net::Session* s) { if (g_step == Step::Done || !Due()) return; if (g_step == Step::Arm) { - // join: the spawn lands while slot 1 is seated and loading, so only the world-ready replay carries it. - const bool ready = Mode() == "join" ? (s->IsSlotReady(1) && !s->IsSlotWorldReady(1)) : s->IsSlotWorldReady(1); + // join: the spawn lands while slot 1 is seated and loading, so only the world-ready replay carries it; a + // slot already world-ready when first seen is a window missed, the drill unable to do its part. + if (Mode() == "join" && s->IsSlotWorldReady(1)) { Abandon("the join window passed: slot 1 was already world-ready"); return; } + const bool ready = Mode() == "join" ? s->IsSlotReady(1) : s->IsSlotWorldReady(1); if (!ready) return; void* me = coop::players::Registry::Get().Local(); ue_wrap::FVector at{}; @@ -108,13 +113,14 @@ void ClientTick() { // Both ways in have run once nothing waits in the park: the broadcast or replay spawned, and a park handed back. const int mirrors = coop::atv_sync::RuntimeMirrors(); const bool parkedOk = Mode() != "park" || coop::atv_sync::ParkedEver() > g_parked0; - if (mirrors >= 1 && coop::atv_sync::ParkPending() == 0 && parkedOk) { - if (mirrors != 1) { - char why[120]; - std::snprintf(why, sizeof(why), "%d runtime ATV mirrors stand here for one spawn", mirrors); - Fail(why); - return; - } + if (mirrors > 1) { + char why[120]; + std::snprintf(why, sizeof(why), "%d runtime ATV mirrors stand here for one spawn", mirrors); + Fail(why); + return; + } + g_onceReads = mirrors == 1 && coop::atv_sync::ParkPending() == 0 && parkedOk ? g_onceReads + 1 : 0; + if (g_onceReads >= kOnceChecks) { // one mirror, nothing parked, held a second: no later delivery doubled it UE_LOGI("[ATV-SPAWN-DRILL] client DONE in session %d (%s): one runtime ATV mirror stands here%s -- PASS", g_session, Mode().c_str(), Mode() == "park" ? ", the park having held and handed back its spawn" : ""); g_step = Step::Done; @@ -141,6 +147,7 @@ void OnDisconnect() { g_stepMs = g_nextCheckMs = 0; g_announced0 = g_parked0 = 0; g_baselined = false; + g_onceReads = 0; } } // namespace coop::dev::atv_spawn_drill diff --git a/src/votv-coop/src/coop/dev/dev_lanes.cpp b/src/votv-coop/src/coop/dev/dev_lanes.cpp index cf0a9f127..6c3bd373e 100644 --- a/src/votv-coop/src/coop/dev/dev_lanes.cpp +++ b/src/votv-coop/src/coop/dev/dev_lanes.cpp @@ -16,11 +16,11 @@ #include "coop/dev/desk_diag.h" // [dev] desk/console divergence census #include "coop/dev/door_drill.h" // [dev] whether a remote player counts in a door's own sensor #include "coop/dev/drive_selftest.h" // [dev] rack-lane e2e circles -#include "coop/dev/drone_call_drill.h" +#include "coop/dev/drone_call_drill.h" // [dev] a client's console press flies the host's drone #include "coop/dev/atv_spawn_drill.h" // [dev] a runtime ATV reaches each client once #include "coop/dev/throw_drill.h" // [dev] a thrown container keeps its contents on the host #include "coop/dev/fall_drill.h" // [dev] a falling host birth lands on the client where it lands on the host -#include "coop/dev/sack_drill.h" // [dev] a client's point sack is paid by the host, once // [dev] a client's console press flies the host's drone +#include "coop/dev/sack_drill.h" // [dev] a client's point sack is paid by the host, once #include "coop/dev/server_upgrade_drill.h" // [dev] a client's install and take-out at a server box #include "coop/dev/server_drill.h" // [dev] a server box's break is the host's; a client's repair runs there #include "coop/dev/sat_console_drill.h" // [dev] a client's SAT console commands run on the host @@ -227,10 +227,10 @@ void TickDrills(coop::net::Session& session) { coop::dev::container_opener_probe::Tick(&session); // [dev] far containers' openers and the reach to them (latched read when off) coop::dev::container_view_drill::Tick(&session); // [dev] the view-close drill (a single bool read when off) coop::dev::physmods_drill::Tick(&session); // [dev] the module-plug drill (a single bool read when off) - coop::dev::throw_drill::Tick(&session); // [dev] the throw drill (a latched string compare when off) - coop::dev::atv_spawn_drill::Tick(&session); // [dev] the ATV spawn drill (a latched string compare when off) - coop::dev::fall_drill::Tick(&session); // [dev] the fall drill (a latched string compare when off) - coop::dev::sack_drill::Tick(&session); // [dev] the point sack drill (a latched string compare when off) + coop::dev::throw_drill::Tick(&session); // [dev] the throw drill (latched string compares when off) + coop::dev::atv_spawn_drill::Tick(&session); // [dev] the ATV spawn drill (latched string compares when off) + coop::dev::fall_drill::Tick(&session); // [dev] the fall drill (a latched bool read when off) + coop::dev::sack_drill::Tick(&session); // [dev] the point sack drill (a latched bool read when off) coop::dev::drone_call_drill::Tick(&session); // [dev] the drone console drill (a single bool read when off) coop::dev::server_upgrade_drill::Tick(&session); // [dev] the server upgrade drill (a single bool read when off) coop::dev::server_drill::Tick(&session); // [dev] the server break drill (two short string compares when off) diff --git a/src/votv-coop/src/coop/dev/drive_drill_verbs.h b/src/votv-coop/src/coop/dev/drive_drill_verbs.h index 41b645537..6e65e0b04 100644 --- a/src/votv-coop/src/coop/dev/drive_drill_verbs.h +++ b/src/votv-coop/src/coop/dev/drive_drill_verbs.h @@ -1,6 +1,6 @@ // coop/dev/drive_drill_verbs.h -- the player's own verbs the drive drill drives, each the game's own, as a player's // input reaches it: the pocket, the take-out from the inventory, the hand's pickup and throw. A private header of -// coop/dev/drive_drill.cpp (src tree, not include/). +// coop/dev/drive_drill.cpp and coop/dev/throw_drill.cpp (src tree, not include/). #pragma once diff --git a/src/votv-coop/src/coop/dev/fall_drill.cpp b/src/votv-coop/src/coop/dev/fall_drill.cpp index 6f0e41b68..e0adcb1ba 100644 --- a/src/votv-coop/src/coop/dev/fall_drill.cpp +++ b/src/votv-coop/src/coop/dev/fall_drill.cpp @@ -22,6 +22,7 @@ #include #include +#include #include namespace coop::dev::fall_drill { @@ -44,7 +45,7 @@ enum class Step : uint8_t { Arm, Track, Rest, End, Done }; Step g_step = Step::Arm; uint64_t g_stepMs = 0; uint64_t g_nextCheckMs = 0; -uint64_t g_armMs = 0; // CLIENT: when it armed; an end before this is not the drill's +uint64_t g_baseMs = 0; // CLIENT: the session's first tick; an end before it is not the drill's int g_session = 1; ue_wrap::CachedObjRef g_drive; // HOST: the dropped drive uint32_t g_eid = 0; @@ -127,30 +128,36 @@ void HostTick(coop::net::Session* s) { } void ClientTick() { + // The baseline is the session's first tick, before this world loads: the host drops on its own view of the + // slot's world-ready, which can come before this peer's own, so the end may land before the arm. + if (g_baseMs == 0) g_baseMs = ::GetTickCount64(); switch (g_step) { case Step::Arm: if (!coop::net_pump::HasAnnouncedWorldReady() || coop::join_progress::CurrentPhase() != coop::join_progress::Phase::Idle) return; - g_armMs = ::GetTickCount64(); Next(Step::End); return; case Step::End: { const uint64_t now = ::GetTickCount64(); if (now < g_nextCheckMs) return; g_nextCheckMs = now + kCheckEveryMs; + // The newest end since the baseline whose prop is a drive here: the host's dropped drive. + coop::prop_drive_stream::AppliedEnd ends[8]; + const size_t n = coop::prop_drive_stream::RecentEnds(ends, 8); + void* actor = nullptr; uint32_t eid = 0; ue_wrap::FVector pose{}; - uint64_t atMs = 0; - void* actor = nullptr; - if (coop::prop_drive_stream::LastAppliedEnd(&eid, &pose, &atMs) && atMs > g_armMs) - actor = coop::remote_prop::ResolveLiveActorByEid(eid); - if (!actor || R::ClassNameOf(actor) != kDriveCls) { + for (size_t k = 0; k < n && !actor; ++k) { + if (ends[k].atMs <= g_baseMs) continue; + void* a = coop::remote_prop::ResolveLiveActorByEid(ends[k].eid); + if (a && R::ClassNameOf(a) == kDriveCls) { actor = a; eid = ends[k].eid; pose = ends[k].pose; } + } + ue_wrap::FVector here{}; + if (!actor || !E::TryGetActorLocation(actor, here)) { if (Expired(kEndBoundMs)) Fail("no stream end of a fallen drive came from the host within 60 s"); return; } - ue_wrap::FVector here{}; - if (!E::TryGetActorLocation(actor, here)) return; const float off = Dist(here, pose); if (off > kTolCm) { char why[160]; @@ -182,7 +189,7 @@ void OnDisconnect() { if (!Enabled()) return; ++g_session; g_step = Step::Arm; - g_stepMs = g_nextCheckMs = g_armMs = 0; + g_stepMs = g_nextCheckMs = g_baseMs = 0; g_drive.Reset(); g_eid = 0; g_fromZ = 0.f; diff --git a/src/votv-coop/src/coop/dev/sack_drill.cpp b/src/votv-coop/src/coop/dev/sack_drill.cpp index 6da686035..811b511b4 100644 --- a/src/votv-coop/src/coop/dev/sack_drill.cpp +++ b/src/votv-coop/src/coop/dev/sack_drill.cpp @@ -15,16 +15,17 @@ #include "ue_wrap/core/asset_load.h" #include "ue_wrap/core/cached_obj_ref.h" #include "ue_wrap/core/log.h" +#include "ue_wrap/core/object_index.h" #include "ue_wrap/core/reflection.h" #include "ue_wrap/core/reflection_props.h" #include "ue_wrap/engine/engine.h" #include "ue_wrap/world/economy.h" -#include "ue_wrap/world/world_singleton.h" #include #include #include +#include #include #include @@ -41,7 +42,7 @@ constexpr float kDropCm = 20.f; // above its feet's height, so it se constexpr uint64_t kPayBoundMs = 60000; // HOST: the spawn to the pay (the client's find, aim and press) constexpr uint64_t kFindBoundMs = 60000; // CLIENT: world-ready to the sack's mirror constexpr uint64_t kSeenBoundMs = 30000; // CLIENT: the press to its mirrored balance rising -constexpr uint64_t kReadyBoundMs = 3000; // CLIENT: the aim to the player's action list holding the sack's +constexpr uint64_t kReadyBoundMs = 3000; // CLIENT: the aim to the player's look settling on the sack constexpr uint64_t kCheckEveryMs = 250; constexpr uint64_t kSettleBoundMs = 10000; // CLIENT: the mirror's find to it at rest (it is spawned above the floor) constexpr int kRestChecks = 4; // ...that many reads in a row, a check apart, within a centimetre @@ -85,7 +86,7 @@ int32_t SackPoints(void* sack) { return v; } -// The player's own look state, named when its action list never takes the sack: which of +// The player's own look state, named when its look never settles on the sack: which of // selectedAction's gates held (its look-at actor, the look flag, an open interface, the trace's hit). void SayLookState(void* player, void* sack) { void* cls = R::ClassOf(player); @@ -103,6 +104,24 @@ void SayLookState(void* player, void* sack) { "trace hit=%p", look, sack, flag, ui, E::ReadMainPlayerHitActor(player)); } +// CLIENT: the point sack within reach of an aim from where this player stands, the nearest -- the host spawned it +// a metre before this player's puppet; another sack in the world is not the drill's. +constexpr float kNearCm = 400.f; +struct Near { ue_wrap::FVector from; void* best; float bestCm; }; +void* NearestSack(void* player) { + Near ctx{{}, nullptr, kNearCm}; + void* cls = ue_wrap::object_index::ClassByName(kSackClass); + if (!cls || !E::TryGetActorLocation(player, ctx.from)) return nullptr; + ue_wrap::object_index::ForEachInstance(cls, [](void* c, void* obj, int32_t) { + auto* n = static_cast(c); + ue_wrap::FVector at{}; + if (R::NameStartsWith(R::NameOf(obj), L"Default__") || !E::TryGetActorLocation(obj, at)) return; + const float cm = std::hypot(at.X - n->from.X, at.Y - n->from.Y); + if (cm < n->bestCm) { n->best = obj; n->bestCm = cm; } + }, &ctx); + return ctx.best; +} + void HostTick(coop::net::Session* s) { if (g_step == Step::Done) return; const uint64_t now = ::GetTickCount64(); @@ -133,7 +152,10 @@ void HostTick(coop::net::Session* s) { return; } int32_t now1 = 0; - if (!ue_wrap::economy::ReadPoints(&now1)) return; + if (!ue_wrap::economy::ReadPoints(&now1)) { + if (Expired(kPayBoundMs)) Abandon("the host's balance is unread"); + return; + } if (paid != g_paid0 + 1 || now1 - g_points0 != g_sackPoints) { char why[160]; std::snprintf(why, sizeof(why), "the host paid %llu redemption(s) and its balance moved by %d, not one and %d", @@ -158,12 +180,15 @@ void ClientTick() { Next(Step::Find); return; case Step::Find: { - void* sack = ue_wrap::world_singleton::Find(kSackClass); + void* sack = NearestSack(player); if (!sack) { - if (Expired(kFindBoundMs)) Abandon("no point sack's mirror reached this client within 60 s"); + if (Expired(kFindBoundMs)) Abandon("no point sack's mirror stood near this client within 60 s"); + return; + } + if (!ue_wrap::economy::ReadPoints(&g_points0)) { + if (Expired(kFindBoundMs)) Abandon("this client's balance is unread"); return; } - if (!ue_wrap::economy::ReadPoints(&g_points0)) return; g_sack.Set(sack); g_restReads = 0; UE_LOGI("[SACK-DRILL] client: the sack's mirror is here; balance %d", g_points0); @@ -198,19 +223,24 @@ void ClientTick() { if (st == coop::director::AimFan::State::Aimed) Next(Step::Ready); return; } - case Step::Ready: // the player's own action list, rebuilt after the look changed, holds the sack's action - if (E::MainPlayerHasSelectedAction(player)) { Next(Step::Press); return; } + case Step::Ready: // the trace on the sack, then the player's look settled on it (a later tick than the trace) if (E::ReadMainPlayerHitActor(player) != g_sack.Get()) { // the trace left it: aim again g_fan = coop::director::AimFan(); Next(Step::Aim); return; } + if (E::MainPlayerHasSelectedAction(player)) { Next(Step::Press); return; } if (Expired(kReadyBoundMs)) { SayLookState(player, g_sack.Get()); - Abandon("the player's action list never held an action for the sack"); + Abandon("the player's look never settled on the sack"); } return; case Step::Press: + if (E::ReadMainPlayerHitActor(player) != g_sack.Get()) { // the press goes to whatever the trace holds + g_fan = coop::director::AimFan(); + Next(Step::Aim); + return; + } if (!E::CallMainPlayerUseSelectedAction(player)) { Abandon("useSelectedAction did not dispatch"); return; } UE_LOGI("[SACK-DRILL] client pressed the sack"); Next(Step::Seen); diff --git a/src/votv-coop/src/coop/dev/throw_drill.cpp b/src/votv-coop/src/coop/dev/throw_drill.cpp index dd489aab4..aac45c545 100644 --- a/src/votv-coop/src/coop/dev/throw_drill.cpp +++ b/src/votv-coop/src/coop/dev/throw_drill.cpp @@ -29,6 +29,7 @@ #include #include +#include #include #include diff --git a/src/votv-coop/src/coop/props/container_birth.cpp b/src/votv-coop/src/coop/props/container_birth.cpp index 2b40a0e72..d08b5f4e7 100644 --- a/src/votv-coop/src/coop/props/container_birth.cpp +++ b/src/votv-coop/src/coop/props/container_birth.cpp @@ -72,7 +72,9 @@ void Push(std::vector& births, void* actor, uint8_t authorSlot) { } // namespace -uint32_t g_endedGone = 0, g_endedExpired = 0; +namespace { +uint32_t g_endedGone = 0, g_endedExpired = 0; // the throw drill's counts; process-long, the drill baselines +} // namespace void NoteAuthoredBirth(void* actor) { if (IsHost() || !ci::IsContainer(actor)) return; diff --git a/src/votv-coop/src/coop/props/prop_drive_stream.cpp b/src/votv-coop/src/coop/props/prop_drive_stream.cpp index 6826fe257..58c9a6dce 100644 --- a/src/votv-coop/src/coop/props/prop_drive_stream.cpp +++ b/src/votv-coop/src/coop/props/prop_drive_stream.cpp @@ -123,21 +123,18 @@ bool YieldedAtOrAfter(uint32_t eid, uint8_t gen) { return y != g_yieldedGen.end() && !GenAfter(gen, y->second); } +// The last stream ends applied here, a ring of eight: a poll a quarter-second apart reads them all. +constexpr size_t kRecentEnds = 8; +AppliedEnd g_ends[kRecentEnds]; +size_t g_endCount = 0; // ends recorded since the session began; the newest is at (g_endCount - 1) % kRecentEnds + // The end edge on a live prop: the final pose, then the host's physics flags through the same // parity restore the join's converge applies after its teleport, then the velocity -- only into // a body this module parked and the flags leave simulating, and only when it is not zero, since // assigning a velocity wakes a body at rest. -struct AppliedEnd { - bool have = false; - uint32_t eid = 0; - ue_wrap::FVector pose{}; - uint64_t atMs = 0; -}; -AppliedEnd g_lastEnd; - void ApplyEnd(void* actor, const coop::net::PropDriveEndPayload& p, bool parkedHere) { E::SetActorLocation(actor, ue_wrap::FVector{p.x, p.y, p.z}); - g_lastEnd = AppliedEnd{true, p.eid, ue_wrap::FVector{p.x, p.y, p.z}, ::GetTickCount64()}; + g_ends[g_endCount++ % kRecentEnds] = AppliedEnd{p.eid, ue_wrap::FVector{p.x, p.y, p.z}, ::GetTickCount64()}; E::SetActorRotation(actor, ue_wrap::FRotator{p.pitch, p.yaw, p.roll}); // The host's frozen and sleep at the end first: a hook that bites a frozen prop unfreezes it on // the host only (hook_C's attach_a runs setPropProps), so a copy still frozen here takes the @@ -158,12 +155,11 @@ void ApplyEnd(void* actor, const coop::net::PropDriveEndPayload& p, bool parkedH } // namespace -bool LastAppliedEnd(uint32_t* eid, ue_wrap::FVector* pose, uint64_t* atMs) { - if (!g_lastEnd.have) return false; - *eid = g_lastEnd.eid; - *pose = g_lastEnd.pose; - *atMs = g_lastEnd.atMs; - return true; +size_t RecentEnds(AppliedEnd* out, size_t max) { + const size_t have = g_endCount < kRecentEnds ? g_endCount : kRecentEnds; + size_t n = 0; + for (; n < have && n < max; ++n) out[n] = g_ends[(g_endCount - 1 - n) % kRecentEnds]; + return n; } void TickApplyAndDrive(coop::net::Session& s) { @@ -364,6 +360,7 @@ void OnDisconnect() { g_yieldedGen.clear(); g_miss.clear(); g_pendingEnd.clear(); + g_endCount = 0; g_batch.clear(); } diff --git a/src/votv-coop/src/harness/autotest/autotest_dmghazard.cpp b/src/votv-coop/src/harness/autotest/autotest_dmghazard.cpp index efd89b71e..35a5807f9 100644 --- a/src/votv-coop/src/harness/autotest/autotest_dmghazard.cpp +++ b/src/votv-coop/src/harness/autotest/autotest_dmghazard.cpp @@ -6,11 +6,11 @@ #include "harness/autotest.h" -#include "coop/config/config.h" #include "coop/player/player_damage.h" #include "coop/player/puppet_drive.h" #include "coop/player/remote_player.h" #include "coop/player/players_registry.h" +#include "ue_wrap/core/cached_obj_ref.h" #include "ue_wrap/core/call.h" #include "ue_wrap/core/game_thread.h" #include "ue_wrap/core/log.h" @@ -27,7 +27,6 @@ namespace { namespace R = ue_wrap::reflection; namespace GT = ue_wrap::game_thread; -namespace cfg = coop::config; // Bounded spin-wait on a game-thread task's completion flag: true if the task signalled, false if // it never completed within timeoutMs -- which means the posted task faulted and the SEH firewall @@ -39,18 +38,12 @@ bool WaitDone(const std::shared_ptr>& d, int timeoutMs) { } // ===================== puppet-damage hazard PROBE ===================== -// mainPlayer_C carries no per-actor health: it lives on UsaveSlot_C, reached through -// GameInstance->save_gameInst, one store per machine (vitals.h resolves it by name). So a damage -// entry invoked on an UNPOSSESSED host-side puppet -- a second mainPlayer_C, GetController()==null -// -- can drain the HOST'S OWN health, and only a runtime measurement settles it: the `addDamage` -// skipSetting guard and the subtraction itself live in BP bytecode. -// HOST: read own saveSlot.health, invoke a damage entry on the slot-1 puppet, re-read. A DROP -// means the hazard is real and native damage on a puppet has to be INTERCEPTED, not merely -// relayed -- which coop/player/player_damage.h does for the three impact entries. No drop, -// and a LOCAL control on the host's own player separates an early-out from a call that never -// landed. Health is restored after; the hit is 5 of ~100. -// CLIENT: connects, so the puppet exists. Host-only verdict. -// A null result also fits a wisp grab: wisp_attack_sync PRE-cancels Add Player Damage during one. +// mainPlayer_C carries no per-actor health: it lives on UsaveSlot_C, one store per machine, so a damage +// or fire entry run on an UNPOSSESSED host-side puppet (a second mainPlayer_C) could drain or burn the +// HOST. coop/player/player_damage refuses the damage verb and ignite on a puppet; this probe fires both +// and addDamage at the slot-1 puppet and judges by those refusals and the host's own health, the same +// verb at the host's own player the control that the calls land. Health is restored after; the hit is +// 5 of ~100. The client only connects, so the puppet exists. Host-only verdict. // Invoke AmainPlayer_C::"Add Player Damage"(Damage) on `target`; true iff the UFunction resolved // and the call dispatched. A raw ParamFrame, so the probe can aim at a puppet. Game-thread only. @@ -84,22 +77,24 @@ bool InvokeAddDamage(void* target, void* sourceActor, float damage) { return ue_wrap::Call(target, f); } -// GT-posted wrappers: invoke, log the dispatch, bounded-wait for completion. `who` is a string -// literal, so capturing it by pointer is safe. -void InvokeAddPlayerDamageGT(void* target, float damage, const char* who) { +// GT-posted wrappers: invoke, log the dispatch, bounded-wait for completion. The bodies are held by +// slot and serial and resolved inside the game-thread task. `who` is a string literal, so capturing it +// by pointer is safe. +void InvokeAddPlayerDamageGT(const ue_wrap::CachedObjRef& target, float damage, const char* who) { auto done = std::make_shared>(0); GT::Post([target, damage, who, done] { - const bool ok = InvokeAddPlayerDamage(target, damage); + const bool ok = InvokeAddPlayerDamage(target.Get(), damage); UE_LOGI("dmghazard[host]: Add Player Damage(%.0f) on %s dispatched=%d", damage, who, ok ? 1 : 0); done->store(1); }); WaitDone(done, 8000); } -void InvokeAddDamageGT(void* target, void* sourceActor, float damage, const char* who) { +void InvokeAddDamageGT(const ue_wrap::CachedObjRef& target, float damage, const char* who) { auto done = std::make_shared>(0); - GT::Post([target, sourceActor, damage, who, done] { - const bool ok = InvokeAddDamage(target, sourceActor, damage); + GT::Post([target, damage, who, done] { + void* t = target.Get(); + const bool ok = InvokeAddDamage(t, t, damage); UE_LOGI("dmghazard[host]: addDamage(%.0f, skipSetting=false) on %s dispatched=%d", damage, who, ok ? 1 : 0); done->store(1); }); @@ -136,14 +131,15 @@ bool InvokeIgnite(void* target, float fuel) { return ue_wrap::Call(target, f); } -// The puppet's own isBurning, by its reflected bool storage. -1 unread. Game-thread only. -int ReadBurningGT(void* actor) { +// The puppet's own isBurning, by its reflected bool storage, read in a game-thread task. -1 unread. +int ReadBurningGT(const ue_wrap::CachedObjRef& ref) { auto done = std::make_shared>(0); auto out = std::make_shared(-1); - GT::Post([actor, out, done] { + GT::Post([ref, out, done] { int32_t off = -1; uint8_t mask = 0; - if (actor && R::IsLive(actor) && R::FindBoolProperty(R::ClassOf(actor), L"isBurning", off, mask)) + void* actor = ref.Get(); + if (actor && R::FindBoolProperty(R::ClassOf(actor), L"isBurning", off, mask)) *out = (static_cast(actor)[off] & mask) ? 1 : 0; done->store(1); }); @@ -181,25 +177,25 @@ void ProbeDamageHazardOnHost() { } // The puppet is the host's own milestone after the slot's world-ready: it spawns once the peer's // pose arrives. Read every 50 ms until it and this machine's player resolve, within a phase. - auto puppet = std::make_shared(nullptr); - auto local = std::make_shared(nullptr); + auto puppet = std::make_shared(); + auto local = std::make_shared(); constexpr DWORD kPuppetMs = 30'000; - for (DWORD waited = 0; (!*puppet || !*local) && waited < kPuppetMs; waited += kPollMs) { + for (DWORD waited = 0; (!puppet->Get() || !local->Get()) && waited < kPuppetMs; waited += kPollMs) { auto done = std::make_shared>(0); GT::Post([puppet, local, done] { void* p = coop::puppet_drive::Puppet(1).GetActor(); - if (p && R::IsLive(p)) *puppet = p; + if (p && R::IsLive(p)) puppet->Set(p); void* mp = coop::players::Registry::Get().Local(); - if (mp && R::IsLive(mp)) *local = mp; + if (mp && R::IsLive(mp)) local->Set(mp); done->store(1); }); WaitDone(done, 8000); - if (!*puppet || !*local) ::Sleep(kPollMs); + if (!puppet->Get() || !local->Get()) ::Sleep(kPollMs); } const float before = ReadHostHealthGT(); - if (!*puppet || !*local || before < 0.f) { + if (!puppet->Get() || !local->Get() || before < 0.f) { UE_LOGW("dmghazard[host]: VERDICT INCONCLUSIVE -- puppet=%p local=%p health=%.2f unread after world-ready", - *puppet, *local, before); + puppet->Get(), local->Get(), before); UE_LOGI("dmghazard[host]: DONE"); return; } @@ -216,7 +212,7 @@ void ProbeDamageHazardOnHost() { // 2. addDamage at the puppet, the entry a native hit forwards to: its nested verb is refused. const uint32_t d2 = PD::RefusedDamage(); const float h2 = ReadHostHealthGT(); - InvokeAddDamageGT(*puppet, *puppet, 5.f, "PUPPET"); + InvokeAddDamageGT(*puppet, 5.f, "PUPPET"); const Outcome o2 = Await([d2] { return PD::RefusedDamage() > d2; }, healthBelow(h2)); UE_LOGI("dmghazard[host]: 'addDamage' at the puppet -> %s (health %.2f -> %.2f)", Name(o2), h2, ReadHostHealthGT()); @@ -225,11 +221,11 @@ void ProbeDamageHazardOnHost() { const uint32_t i3 = PD::RefusedIgnites(); { auto d = std::make_shared>(0); - void* target = *puppet; - GT::Post([target, d] { InvokeIgnite(target, 10.f); d->store(1); }); + const ue_wrap::CachedObjRef target = *puppet; + GT::Post([target, d] { InvokeIgnite(target.Get(), 10.f); d->store(1); }); WaitDone(d, 8000); } - void* pup = *puppet; + const ue_wrap::CachedObjRef pup = *puppet; const Outcome o3 = Await([i3] { return PD::RefusedIgnites() > i3; }, [pup] { return ReadBurningGT(pup) == 1; }); UE_LOGI("dmghazard[host]: 'ignite' at the puppet -> %s (puppet isBurning=%d)", Name(o3), ReadBurningGT(pup)); diff --git a/src/votv-coop/src/ue_wrap/engine/engine_mainplayer.cpp b/src/votv-coop/src/ue_wrap/engine/engine_mainplayer.cpp index 10c897250..b503be20f 100644 --- a/src/votv-coop/src/ue_wrap/engine/engine_mainplayer.cpp +++ b/src/votv-coop/src/ue_wrap/engine/engine_mainplayer.cpp @@ -293,8 +293,13 @@ bool MainPlayerHasSelectedAction(void* mainPlayer) { if (!mainPlayer || !R::IsLive(mainPlayer)) return false; void* fn = R::FindDispatchFunctionCached(R::ClassOf(mainPlayer), L"selectedAction"); if (!fn) return false; - ParamFrame f(fn); // the FString out param is written empty on both paths, so nothing is owned - return f.valid() && Call(mainPlayer, f) && f.Get(L"return"); + ParamFrame f(fn); + if (!f.valid() || !Call(mainPlayer, f)) return false; + // asName is an out FString: three paths write it empty, the names list's path a copy of a name, which + // the engine allocated and the frame now owns, so it is freed here. + struct { wchar_t* data; int32_t num; int32_t max; } name{}; + if (f.GetRaw(L"asName", &name, sizeof(name)) && name.data) R::EngineFree(name.data); + return f.Get(L"return"); } bool CallMainPlayerHoldObject(void* mainPlayer, void* target, bool& collected) { From ff9f9a66767a1e641f6b452da2a48d9e22604719 Mon Sep 17 00:00:00 2001 From: pelmentor Date: Sat, 10 Oct 2026 13:41:32 +0600 Subject: [PATCH 074/306] [net] atv: a client's runtime mirror leaves atv_sync for its own file atv_sync.cpp stood at 811 lines. The client's receive of a runtime ATV, its spawn under the host's synthetic key and its destroy, moves to atv_runtime_mirror.cpp with the park's two counters. The map stays atv_sync.cpp's: the sibling finds a row, adopts a fresh-spawned mirror and drops a row through calls in atv_sync_internal.h, and the destroy now re-finds its row by key after DestroyMirror instead of holding an iterator across it. atv_sync.cpp is 746 lines. Co-Authored-By: Claude Opus 5.5 --- .../coop/interactables/atv_sync_internal.h | 15 ++- src/votv-coop/sources.cmake | 1 + .../coop/interactables/atv_runtime_mirror.cpp | 116 ++++++++++++++++++ .../src/coop/interactables/atv_sync.cpp | 109 ++++------------ 4 files changed, 153 insertions(+), 88 deletions(-) create mode 100644 src/votv-coop/src/coop/interactables/atv_runtime_mirror.cpp diff --git a/src/votv-coop/include/coop/interactables/atv_sync_internal.h b/src/votv-coop/include/coop/interactables/atv_sync_internal.h index bc45d9fa3..31825045e 100644 --- a/src/votv-coop/include/coop/interactables/atv_sync_internal.h +++ b/src/votv-coop/include/coop/interactables/atv_sync_internal.h @@ -1,6 +1,7 @@ // coop/interactables/atv_sync_internal.h -- the per-ATV record, shared by the ATV lane's // translation units. atv_sync.cpp owns the map of these and every structural change to it; -// atv_corrector.cpp mutates the fields of ONE entry when a packet arrives. Nothing outside +// atv_corrector.cpp mutates the fields of ONE entry when a packet arrives, and +// atv_runtime_mirror.cpp asks for its two changes through the calls at the end. Nothing outside // coop/interactables/ includes this -- the lane's public surface is // coop/interactables/atv_sync.h. @@ -12,6 +13,9 @@ #include #include #include +#include + +namespace coop::net { class Session; } namespace coop::atv_sync { @@ -74,4 +78,13 @@ inline uint64_t NowMs() { std::chrono::steady_clock::now().time_since_epoch()).count()); } +// atv_sync.cpp's surface for atv_runtime_mirror.cpp, a client receiving a runtime ATV: the lane's session, +// whether the index belongs to this world, the row under a key, and that half's two structural changes -- a +// fresh-spawned mirror adopted under the host's synthetic key, a row dropped with its actor's key. Game thread. +coop::net::Session* LaneSession(); +bool IndexCurrent(); +const AtvEntry* FindEntry(const std::wstring& key); +void AdoptClientMirror(const std::wstring& key, void* actor); +void EraseEntry(const std::wstring& key); + } // namespace coop::atv_sync diff --git a/src/votv-coop/sources.cmake b/src/votv-coop/sources.cmake index 5fce4c2c0..81fdfff97 100644 --- a/src/votv-coop/sources.cmake +++ b/src/votv-coop/sources.cmake @@ -224,6 +224,7 @@ set(VOTVCOOP_SOURCES src/coop/world/coord_tower_ops.cpp src/coop/world/coord_tower_rows.cpp src/coop/interactables/atv_sync.cpp + src/coop/interactables/atv_runtime_mirror.cpp src/coop/interactables/atv_spawn_park.cpp src/coop/interactables/atv_condition_sync.cpp src/coop/interactables/atv_corrector.cpp diff --git a/src/votv-coop/src/coop/interactables/atv_runtime_mirror.cpp b/src/votv-coop/src/coop/interactables/atv_runtime_mirror.cpp new file mode 100644 index 000000000..1bb3cf1f3 --- /dev/null +++ b/src/votv-coop/src/coop/interactables/atv_runtime_mirror.cpp @@ -0,0 +1,116 @@ +// coop/interactables/atv_runtime_mirror.cpp -- a client's mirror of a runtime ATV (declared in +// coop/interactables/atv_sync.h). The host announces an ATV spawned mid-session under a synthetic key; this +// client fresh-spawns a native ATV for it, physics on so it is grabbable, and destroys it when the host's goes. +// A spawn landing while this world is not ready waits in the park (atv_spawn_park.h). The map stays +// atv_sync.cpp's: this half asks it to adopt or drop a row (atv_sync_internal.h). + +#include "coop/interactables/atv_sync.h" +#include "atv_spawn_park.h" +#include "coop/config/config.h" +#include "coop/config/config_registry.h" +#include "coop/interactables/atv_sync_internal.h" +#include "coop/net/protocol.h" +#include "coop/net/session.h" +#include "coop/net/wire_key_util.h" +#include "coop/session/world_load_episode.h" +#include "ue_wrap/core/log.h" +#include "ue_wrap/core/reflection.h" +#include "ue_wrap/devices/atv.h" +#include "ue_wrap/engine/world_identity.h" + +#include +#include +#include + +namespace coop::atv_sync { +namespace { + +namespace R = ue_wrap::reflection; +namespace A = ue_wrap::atv; +using ue_wrap::FVector; +using ue_wrap::FRotator; +using coop::net::StringFromWireKey; + +std::wstring WireClassNameToString(const coop::net::WireClassName& in) { + std::wstring s; + const uint8_t n = in.len <= sizeof(in.data) ? in.len : static_cast(sizeof(in.data)); + s.reserve(n); + for (uint8_t i = 0; i < n; ++i) s.push_back(static_cast(static_cast(in.data[i]))); + return s; +} + +} // namespace + +void OnAtvSpawn(const coop::net::AtvSpawnPayload& payload, uint8_t /*senderPeerSlot*/) { + auto* s = LaneSession(); + if (!s || !s->connected() || s->role() != coop::net::Role::Client) return; + std::wstring synthKey = StringFromWireKey(payload.synthKey); + if (synthKey.empty()) { UE_LOGW("atv: OnAtvSpawn empty synthKey -- dropping"); return; } + // The payload is checked before any wait: a malformed spawn is dropped, only a sound one is deferred. + if (!std::isfinite(payload.x) || !std::isfinite(payload.y) || !std::isfinite(payload.z) || + !std::isfinite(payload.pitch) || !std::isfinite(payload.yaw) || !std::isfinite(payload.roll)) { + UE_LOGW("atv: OnAtvSpawn non-finite pose synthKey='%ls' -- dropping", synthKey.c_str()); + spawn_park::Discard(synthKey); // a malformed spawn is final + return; + } + std::wstring className = WireClassNameToString(payload.className); + if (className.empty()) { + UE_LOGW("atv: OnAtvSpawn empty className synthKey='%ls' -- dropping", synthKey.c_str()); + spawn_park::Discard(synthKey); + return; + } + if (ue_wrap::world_identity::CurrentWorldKind() != ue_wrap::world_identity::WorldKind::Gameplay || + !IndexCurrent() || !coop::world_load_episode::HasQuiesced()) { + spawn_park::Park(synthKey, payload, "the receiving world is not ready"); + return; + } + // The ATV spawn drill's park arm: the first spawn is parked as if the world were not ready, so the park's + // hand-back is what spawns it. + static const bool s_parkFirst = coop::config::ResolveFlag(coop::config_registry::rows::atv_park_first_spawn); + static bool s_parkedFirst = false; + if (s_parkFirst && !s_parkedFirst) { + s_parkedFirst = true; + spawn_park::Park(synthKey, payload, "the drill parks the first spawn"); + return; + } + if (!A::EnsureResolved()) { spawn_park::Park(synthKey, payload, "the ATV wrapper is not resolved"); return; } + if (const AtvEntry* known = FindEntry(synthKey)) { + if (R::IsLiveByIndex(known->actor, known->idx)) { spawn_park::Discard(synthKey); return; } + EraseEntry(synthKey); // a dead row is not "already spawned" + } + const FVector loc{ payload.x, payload.y, payload.z }; + const FRotator rot{ payload.pitch, payload.yaw, payload.roll }; + void* spawned = A::SpawnMirror(className, loc, rot); // physics LEFT ON -- a native idle grabbable ATV + if (!spawned) { + // In a world that reads ready, a refused spawn is the class or the engine saying no: final, not retried. + UE_LOGW("atv: OnAtvSpawn SpawnMirror failed synthKey='%ls' class='%ls' -- not retried", synthKey.c_str(), + className.c_str()); + spawn_park::Discard(synthKey); + return; + } + spawn_park::Discard(synthKey); + AdoptClientMirror(synthKey, spawned); + UE_LOGI("atv: spawned runtime-ATV mirror synthKey='%ls' class='%ls' actor=%p loc=(%.0f, %.0f, %.0f)", + synthKey.c_str(), className.c_str(), spawned, loc.X, loc.Y, loc.Z); +} + +uint32_t ParkedEver() { return spawn_park::ParkedEver(); } +size_t ParkPending() { return spawn_park::Pending(); } + +void OnAtvDestroy(const coop::net::AtvDestroyPayload& payload, uint8_t /*senderPeerSlot*/) { + auto* s = LaneSession(); + if (!s || s->role() == coop::net::Role::Host) return; // client-only + std::wstring synthKey = StringFromWireKey(payload.synthKey); + if (synthKey.empty()) return; + spawn_park::Discard(synthKey); // a destroy ends a spawn still waiting + if (!IndexCurrent()) return; // the pass prunes a dead-world entry itself + const AtvEntry* row = FindEntry(synthKey); + if (!row) return; + const bool destroyed = row->isClientSpawnedMirror && R::IsLiveByIndex(row->actor, row->idx) && + A::DestroyMirror(row->actor); // our own fresh spawn + EraseEntry(synthKey); + UE_LOGI("atv: runtime-ATV mirror synthKey='%ls' %s", synthKey.c_str(), + destroyed ? "destroyed" : "dropped from the lane, not destroyed here"); +} + +} // namespace coop::atv_sync diff --git a/src/votv-coop/src/coop/interactables/atv_sync.cpp b/src/votv-coop/src/coop/interactables/atv_sync.cpp index 17bf1f0c4..8bb4b812b 100644 --- a/src/votv-coop/src/coop/interactables/atv_sync.cpp +++ b/src/votv-coop/src/coop/interactables/atv_sync.cpp @@ -15,8 +15,6 @@ #include "coop/interactables/atv_sync.h" #include "atv_spawn_park.h" -#include "coop/config/config.h" -#include "coop/config/config_registry.h" #include "coop/interactables/atv_corrector.h" #include "coop/dev/atv_eject_drill.h" #include "coop/interactables/atv_condition_sync.h" @@ -28,7 +26,6 @@ #include "coop/net/wire_key_util.h" // WireKeyFromString / StringFromWireKey / FnvKey (shared) #include "coop/player/players_registry.h" // Registry::Local / LocalPeerId / kMaxPeers #include "coop/player/roster_ledger.h" // SubscribeSlotReplaced: a departed author must not hold an ATV -#include "coop/session/world_load_episode.h" #include "ue_wrap/devices/atv.h" #include "ue_wrap/engine/engine.h" // ReadMainPlayerGrabState (grabber authority) + Get/SetActorRootPhysicsVelocity @@ -110,13 +107,6 @@ void FillWireClassName(coop::net::WireClassName& out, const std::wstring& name) for (size_t i = 0; i < name.size() && i < sizeof(out.data); ++i) out.data[out.len++] = static_cast(name[i]); } -std::wstring WireClassNameToString(const coop::net::WireClassName& in) { - std::wstring s; - const uint8_t n = in.len <= sizeof(in.data) ? in.len : static_cast(sizeof(in.data)); - s.reserve(n); - for (uint8_t i = 0; i < n; ++i) s.push_back(static_cast(static_cast(in.data[i]))); - return s; -} // Host: announces a runtime ATV so clients fresh-spawn a native mirror; slot < 0 broadcasts (a // new ATV mid-session), slot >= 0 seeds one joiner. Reads the class and the current pose. @@ -243,7 +233,6 @@ bool IdleWorthSending(AtvEntry& e) { // and an announce). A spawn landing inside the pass window at a join is announced on the next // pass, when the joiner is connected. uint32_t g_indexGen = 0; // world gen of the last completed pass (stale-gen index = EMPTY) -bool IndexCurrent() { return g_indexGen == ue_wrap::world_identity::Generation(); } struct ScanFound { std::wstring wireKey; void* obj; int32_t idx; std::wstring realKey; }; std::vector g_scanFound; // pass scratch (GT-only) bool g_scanIsHost = false; // pass context, captured at pass begin @@ -385,6 +374,8 @@ void RegisterWithScanHub() { } // namespace +bool IndexCurrent() { return g_indexGen == ue_wrap::world_identity::Generation(); } + void Install(coop::net::Session* session) { g_session.store(session, std::memory_order_release); // Install is the per-tick ensure path (net_pump re-calls it until the class loads), latched so @@ -501,66 +492,6 @@ void OnAtvRelease(const coop::net::AtvReleasePayload& payload, uint8_t senderPee key.c_str()); } -void OnAtvSpawn(const coop::net::AtvSpawnPayload& payload, uint8_t /*senderPeerSlot*/) { - auto* s = g_session.load(std::memory_order_acquire); - if (!s || !s->connected() || s->role() != coop::net::Role::Client) return; - std::wstring synthKey = StringFromWireKey(payload.synthKey); - if (synthKey.empty()) { UE_LOGW("atv: OnAtvSpawn empty synthKey -- dropping"); return; } - // The payload is checked before any wait: a malformed spawn is dropped, only a sound one is deferred. - if (!std::isfinite(payload.x) || !std::isfinite(payload.y) || !std::isfinite(payload.z) || - !std::isfinite(payload.pitch) || !std::isfinite(payload.yaw) || !std::isfinite(payload.roll)) { - UE_LOGW("atv: OnAtvSpawn non-finite pose synthKey='%ls' -- dropping", synthKey.c_str()); - spawn_park::Discard(synthKey); // a malformed spawn is final - return; - } - std::wstring className = WireClassNameToString(payload.className); - if (className.empty()) { - UE_LOGW("atv: OnAtvSpawn empty className synthKey='%ls' -- dropping", synthKey.c_str()); - spawn_park::Discard(synthKey); - return; - } - if (ue_wrap::world_identity::CurrentWorldKind() != ue_wrap::world_identity::WorldKind::Gameplay || - !IndexCurrent() || !coop::world_load_episode::HasQuiesced()) { - spawn_park::Park(synthKey, payload, "the receiving world is not ready"); - return; - } - // The ATV spawn drill's park arm: the first spawn is parked as if the world were not ready, so the park's - // hand-back is what spawns it. - static const bool s_parkFirst = coop::config::ResolveFlag(coop::config_registry::rows::atv_park_first_spawn); - static bool s_parkedFirst = false; - if (s_parkFirst && !s_parkedFirst) { - s_parkedFirst = true; - spawn_park::Park(synthKey, payload, "the drill parks the first spawn"); - return; - } - if (!A::EnsureResolved()) { spawn_park::Park(synthKey, payload, "the ATV wrapper is not resolved"); return; } - auto known = g_atvs.find(synthKey); - if (known != g_atvs.end()) { - if (R::IsLiveByIndex(known->second.actor, known->second.idx)) { spawn_park::Discard(synthKey); return; } - if (known->second.actor) g_synthForActor.erase(known->second.actor); - g_atvs.erase(known); // a dead row is not "already spawned" - } - const FVector loc{ payload.x, payload.y, payload.z }; - const FRotator rot{ payload.pitch, payload.yaw, payload.roll }; - void* spawned = A::SpawnMirror(className, loc, rot); // physics LEFT ON -- a native idle grabbable ATV - if (!spawned) { - // In a world that reads ready, a refused spawn is the class or the engine saying no: final, not retried. - UE_LOGW("atv: OnAtvSpawn SpawnMirror failed synthKey='%ls' class='%ls' -- not retried", synthKey.c_str(), - className.c_str()); - spawn_park::Discard(synthKey); - return; - } - spawn_park::Discard(synthKey); - AtvEntry e{}; - e.actor = spawned; - e.idx = R::InternalIndexOf(spawned); - e.isClientSpawnedMirror = true; - g_atvs[synthKey] = std::move(e); - g_synthForActor[spawned] = synthKey; - UE_LOGI("atv: spawned runtime-ATV mirror synthKey='%ls' class='%ls' actor=%p loc=(%.0f, %.0f, %.0f)", - synthKey.c_str(), className.c_str(), spawned, loc.X, loc.Y, loc.Z); -} - uint32_t RuntimeAnnounced() { return g_synthCounter; } int RuntimeMirrors() { @@ -570,25 +501,29 @@ int RuntimeMirrors() { return n; } -uint32_t ParkedEver() { return spawn_park::ParkedEver(); } -size_t ParkPending() { return spawn_park::Pending(); } +// The runtime mirror's half (atv_runtime_mirror.cpp) reaches the map through these; every structural +// change to it stays here (atv_sync_internal.h). +coop::net::Session* LaneSession() { return g_session.load(std::memory_order_acquire); } -void OnAtvDestroy(const coop::net::AtvDestroyPayload& payload, uint8_t /*senderPeerSlot*/) { - auto* s = g_session.load(std::memory_order_acquire); - if (!s || s->role() == coop::net::Role::Host) return; // client-only - std::wstring synthKey = StringFromWireKey(payload.synthKey); - if (synthKey.empty()) return; - spawn_park::Discard(synthKey); // a destroy ends a spawn still waiting - if (!IndexCurrent()) return; // the pass prunes a dead-world entry itself - auto it = g_atvs.find(synthKey); +const AtvEntry* FindEntry(const std::wstring& key) { + auto it = g_atvs.find(key); + return it == g_atvs.end() ? nullptr : &it->second; +} + +void AdoptClientMirror(const std::wstring& key, void* actor) { + AtvEntry e{}; + e.actor = actor; + e.idx = R::InternalIndexOf(actor); + e.isClientSpawnedMirror = true; + g_atvs[key] = std::move(e); + g_synthForActor[actor] = key; +} + +void EraseEntry(const std::wstring& key) { + auto it = g_atvs.find(key); if (it == g_atvs.end()) return; - void* actor = it->second.actor; - const bool destroyed = it->second.isClientSpawnedMirror && R::IsLiveByIndex(actor, it->second.idx) && - A::DestroyMirror(actor); // our own fresh spawn - if (actor) g_synthForActor.erase(actor); + if (it->second.actor) g_synthForActor.erase(it->second.actor); g_atvs.erase(it); - UE_LOGI("atv: runtime-ATV mirror synthKey='%ls' %s", synthKey.c_str(), - destroyed ? "destroyed" : "dropped from the lane, not destroyed here"); } void QueueConnectBroadcastForSlot(int peerSlot) { From b8f07c1efe647abc4db4ec53d7b0cca836a97eff Mon Sep 17 00:00:00 2001 From: pelmentor Date: Sat, 10 Oct 2026 14:32:04 +0600 Subject: [PATCH 075/306] [dev] drills: the park arm skips a hand-back, two drives abandon The ATV park arm parked a hand-back the park already held, silently and to no effect; it now acts only on a spawn the park does not hold, so its park is a fresh one that logs itself (spawn_park::Holds). The ATV pass holds five polls, a second. The fall drill abandons when the ends of two drives landed this session, and the ring's size is one constant. The log drill needs the usual log's writer to be running. The sack drill bounds its re-aims at five, refuses a dead sack at the look and the press, and measures its 4 m in three axes. selectedAction reads its name as R::FString; container_birth keeps one anonymous namespace; the code map names atv_runtime_mirror. Co-Authored-By: Claude Opus 5.5 --- docs/code-map.md | 2 +- .../coop/config/config_registry_rows_dev.inc | 4 +- .../include/coop/dev/atv_spawn_drill.h | 3 +- src/votv-coop/include/coop/dev/fall_drill.h | 4 +- .../include/coop/props/prop_drive_stream.h | 7 ++-- .../src/coop/dev/atv_spawn_drill.cpp | 2 +- src/votv-coop/src/coop/dev/fall_drill.cpp | 18 ++++++--- src/votv-coop/src/coop/dev/log_drill.cpp | 18 +++++++-- src/votv-coop/src/coop/dev/sack_drill.cpp | 37 ++++++++++++------- .../coop/interactables/atv_runtime_mirror.cpp | 6 +-- .../src/coop/interactables/atv_spawn_park.cpp | 1 + .../src/coop/interactables/atv_spawn_park.h | 2 + .../src/coop/props/container_birth.cpp | 4 +- .../src/coop/props/prop_drive_stream.cpp | 3 +- .../src/ue_wrap/engine/engine_mainplayer.cpp | 4 +- 15 files changed, 72 insertions(+), 43 deletions(-) diff --git a/docs/code-map.md b/docs/code-map.md index b10e0d7aa..940e43808 100644 --- a/docs/code-map.md +++ b/docs/code-map.md @@ -25,7 +25,7 @@ One folder, one concept, named after it. `src/votv-coop/src/` holds the implemen | `coop/player/` | remote players and per-player state | `remote_player` + `remote_player_ragdoll`, `puppet_drive`, `puppet_carry_drive`, `puppet_body_yaw`, `puppet_footsteps` (driving the engine pawn), `players_registry`, `player_profile_store` (the host's copy of every player's profile: held in memory, cut to disk with the world save), `roster` + `roster_ledger` (who is in each slot), `nickname_arbiter`, `nick_color`, `nameplate`, `hand_item`, `local_body`, `local_streams`, `client_model` + `skin_registry` + `skin_preview` + `skin_effects` (skins), `movement_ledger`, `player_damage`, `death_revive` + `run_end_travel` (a trip to the menu the player did not ask for is refused and answered with a revive), `ragdoll_gate`, `sleep_sync`, `item_activate`, `flashlight_click_sound`, `stat_orders` (an admin's order to, and query of, one player's stats and effects) + `stat_orders_wire` (their bytes, their checks, and the table of the ones awaiting an answer) | | `coop/props/` | physics props, piles and trash | `prop_lifecycle` (the destroy seam), `prop_snapshot` + `snapshot_census` (the connect snapshot), `remote_prop` with `_spawn`, `_convert`, `_physics`, `_destroy` (the receivers), `host_spawn_watcher`, `prop_key_index`, `prop_synth_key`, `save_identity_bind` + `save_identity_map` (bind save-loaded actors to host eids in the join window), `pile_spawn_bind`, `trash_mirror` (the client's mirror of either trash form, the game's own actor parked), `trash_morph_gate` (the verbs a client refuses so it never authors a transition), `trash_sweep` (the clumps a broom stroke sets rolling: their ids at birth, a carry nobody holds, the roll), `trash_channel`, `trash_grab_intent`, `trash_collect_sync`, `trash_pile_sync`, `trash_use_intercept`, `trash_clump_pose_stream`, `prop_drop_intent`, `prop_spawn_authoring` (did a PLAYER's spawn verb author this birth, or the world's own churn -- the fact the drop-intent seam admits a catalog spawn on), `prop_save_data` (each prop's own save record, by Key), `prop_stick_sync`, `container_contents_sync` + `container_write_policy` (a world container slice, and the host arbitration a client-authored one passes) + `container_park` (the holding pen for a slice whose container has not arrived yet) + `container_birth` (a container that changes hands through the world: the thrower's slice, the host's awaited copy) + `container_view_close` (a player's view into a container ends where the player's reach does), `grab_observer`, `registry_reaper`, `join_membership_sweep`, `unresolved_pose_ledger`, `prop_echo_suppress`, `prop_wire_parity`, `prop_census`, `prop_container_extract`, `prop_destroy_seam`, `prop_fresh_spawn`, `prop_element_tracker`, `prop_sound`, `active_drive` | | `coop/creatures/` | NPCs and creatures | `npc_sync` + `npc_sync_install` (the spawn seam), `npc_mirror`, `npc_adoption`, `npc_pose_host`, `npc_pose_drive`, `npc_world_enum`, `kerfur_convert` with `_host` and `_client`, `kerfur_entity`, `kerfur_reconcile`, `kerfur_prop_adoption`, `kerfur_command`, `kerfur_menu_input`, `kerfur_form_assembler` (the prop-to-NPC conversion cycle), `owner_entity_sync` (creatures whose AI reads the local player: owned per peer, visible to all), `piramid_sync`, `roach_sync`, `wisp_attack_sync`, `wisp_grab_hold`, `wisp_tear_mirror` | -| `coop/interactables/` | keyed device state | doors, lights, light groups, containers, garage, appliances, lockers: `interactable_sync` + `interactable_channel`; `keypad_sync` + `keypad_verbs`, `verb_lanes` (the lanes on a device's own verbs at the script gate, wired in one place), `toggle_verbs` (a symmetric device's state at the verb that writes it), `window_sync`, `grime_sync`, `turbine_sync`, `drone_sync`, `device_occupancy`, `garbage_sync`. The signal workstation: `signal_sync`, `signal_catch_sync`, `signal_wire`, `dish_sync`, `dish_calib_sync`, `download_arm_sync` (the download's arm and reset, the host's), `desk_input_sync`, `desk_sim_sync`, `desk_ping_sync` (the ping's verdict, rolled on the host), `desk_verb_intent` and `desk_verb_effects` (the desk's save-family presses, run by the host, their gloss, stat and sounds sent to the presser), `desk_cursor_sync`, `desk_snd_fx`, `deck_play_sync`, `comp_sync` (the refiner's decode, the host's), `console_state_sync`, `drive_sync`, `drive_payload_sync` (a drive's row, the host's), `eraser_press_intent` (the eraser's delete, run by the host), `drive_rack_sync`, `tape_caddy_sync`, `physmods_sync`, `serverbox_sync`, `server_upgrade_sync` (a server box's physical upgrades), `sat_console_sync` and `sat_console_table` (the SAT console's shared commands, run on a terminal the host keeps for each typist, and which command runs where), `laptop_sync`, `portable_pc_lid` (the portable PC's lid, on its own verb), `laptop_buffer_sync`, `floppybox_sync`, `floppy_slot_sync` and `floppy_slot_entry` (a device's disc slot: the state, and which overlap into it counts as an insert), `meadow_db_sync`, `meadow_db_writers`, `meadow_db_join`, `meadow_db_park` and `meadow_db_hash` (the laptop's signal database, the bodies that write it, a joiner's seed of it, its inbound lines that cannot apply yet, and the database as content hashes). The ATV: `atv_sync`, `atv_spawn_park` (a runtime ATV's spawn held until its world is ready), `atv_corrector`, `atv_hit_guard`, `atv_condition_sync` | +| `coop/interactables/` | keyed device state | doors, lights, light groups, containers, garage, appliances, lockers: `interactable_sync` + `interactable_channel`; `keypad_sync` + `keypad_verbs`, `verb_lanes` (the lanes on a device's own verbs at the script gate, wired in one place), `toggle_verbs` (a symmetric device's state at the verb that writes it), `window_sync`, `grime_sync`, `turbine_sync`, `drone_sync`, `device_occupancy`, `garbage_sync`. The signal workstation: `signal_sync`, `signal_catch_sync`, `signal_wire`, `dish_sync`, `dish_calib_sync`, `download_arm_sync` (the download's arm and reset, the host's), `desk_input_sync`, `desk_sim_sync`, `desk_ping_sync` (the ping's verdict, rolled on the host), `desk_verb_intent` and `desk_verb_effects` (the desk's save-family presses, run by the host, their gloss, stat and sounds sent to the presser), `desk_cursor_sync`, `desk_snd_fx`, `deck_play_sync`, `comp_sync` (the refiner's decode, the host's), `console_state_sync`, `drive_sync`, `drive_payload_sync` (a drive's row, the host's), `eraser_press_intent` (the eraser's delete, run by the host), `drive_rack_sync`, `tape_caddy_sync`, `physmods_sync`, `serverbox_sync`, `server_upgrade_sync` (a server box's physical upgrades), `sat_console_sync` and `sat_console_table` (the SAT console's shared commands, run on a terminal the host keeps for each typist, and which command runs where), `laptop_sync`, `portable_pc_lid` (the portable PC's lid, on its own verb), `laptop_buffer_sync`, `floppybox_sync`, `floppy_slot_sync` and `floppy_slot_entry` (a device's disc slot: the state, and which overlap into it counts as an insert), `meadow_db_sync`, `meadow_db_writers`, `meadow_db_join`, `meadow_db_park` and `meadow_db_hash` (the laptop's signal database, the bodies that write it, a joiner's seed of it, its inbound lines that cannot apply yet, and the database as content hashes). The ATV: `atv_sync`, `atv_runtime_mirror` (a client's mirror of a runtime ATV, its spawn and destroy), `atv_spawn_park` (a runtime ATV's spawn held until its world is ready), `atv_corrector`, `atv_hit_guard`, `atv_condition_sync` | | `coop/world/` | global and ambient state | `time_sync`, `sky_sync`, `weather_sync` with `weather_rain`, `weather_fog`, `weather_lightning`, `weather_redsky`, `weather_event_births`, `firefly_sync`, `spawn_authority` (shared-world spawners run on the host only), `event_fire_sync` + `event_fire_policy` (which received fire a client replays), `event_active_sync`, `event_cue_sync`, `alarm_sync`, `balance_sync`, `email_sync`, `daily_task_sync`, `power_grid` (the generators run on the host) + `power_decay` (their wear dice) + `power_puzzle` (their repair puzzles) + `power_upgrade` (the upgrades an install spent) + `power_panel` (the panel's one author), `world_actor_sync` + `world_actor_mirror` | | `coop/items/` | inventory, the economy, and the deployed items | `player_inventory_sync` + `inventory_wire`, `inventory_pickup_sync`, `order_sync` (shop orders: the client names the row, the host performs and prices it) + `order_queue_sync` (the host's delivery queue mirrored on the clients) + `order_rows` (an order's items on the wire), `coingun_sync` + `coingun_arbiter` + `coingun_collect` (the sell gun and its coins), `save_record_wire`, `hook_sync` + `hook_anchor` + `hook_prop_claim` + `hook_constraint` (the grappling hook and the rope: the owner's stream and the mirrors, the anchor handoff, the props its ties claim, and the tie itself, which lives on the host), `broom_stroke` + `broom_push` (the broom: every stroke is the host's, a client's queued with what it read of its holder, and what a stroke pushes or knocks out of a dispenser streams) | | `coop/comms/` | text chat | `chat_sync`, `chat_feed`, `chat_bubbles`, `chat_log`, `chat_nick_color`, `peer_action_feed` | diff --git a/src/votv-coop/include/coop/config/config_registry_rows_dev.inc b/src/votv-coop/include/coop/config/config_registry_rows_dev.inc index 0b9e413a0..05ad5042d 100644 --- a/src/votv-coop/include/coop/config/config_registry_rows_dev.inc +++ b/src/votv-coop/include/coop/config/config_registry_rows_dev.inc @@ -484,8 +484,8 @@ CFG_STRING(atv_spawn_drill, "atv_spawn_drill", "dev", "", "VOTVCOOP_ATV_SPAWN_DR "world is ready; join -- while it is still loading (the world-ready replay); park -- as run, with " "atv_park_first_spawn=1 on the client. The client's DONE line ends it.") CFG_FLAG(atv_park_first_spawn, "atv_park_first_spawn", "dev", false, "VOTVCOOP_ATV_PARK_FIRST_SPAWN", - "Developer: the first runtime ATV spawn that reaches this client's ready world is parked once, process-wide, as " - "if the world were not (the ATV spawn drill's park arm).") + "Developer: the first runtime ATV spawn that reaches this client's ready world and is not already a park's " + "hand-back is parked once, process-wide, as if the world were not (the ATV spawn drill's park arm).") CFG_STRING(fall_drill, "fall_drill", "dev", "", "VOTVCOOP_FALL_DRILL", false, "Drill (BOTH peers): a host birth that falls lands on the client where it lands on the host. run -- the host " "drops a drive from 4 m once the client's world is ready; the client's DONE line ends it.") diff --git a/src/votv-coop/include/coop/dev/atv_spawn_drill.h b/src/votv-coop/include/coop/dev/atv_spawn_drill.h index 9226921e2..76c18fb1d 100644 --- a/src/votv-coop/include/coop/dev/atv_spawn_drill.h +++ b/src/votv-coop/include/coop/dev/atv_spawn_drill.h @@ -5,7 +5,8 @@ // CLIENT -- once its world is ready, passes when exactly one runtime ATV mirror stands here with nothing parked, // held for a second; run covers either delivery (the broadcast, or the park's hand-back when it lands in // a world not yet ready). In park mode atv_park_first_spawn=1 parks the first spawn that reaches a ready -// world, and the pass also needs the park to have held a spawn this session. +// world unless the park already holds it, and the pass also needs the park to have held a spawn this +// session. // "[ATV-SPAWN-DRILL] FAIL" is the lane failing (--fail-marker), "[ATV-SPAWN-DRILL] ABANDONED" the drill unable to do // its part (--dead-marker). Run with atv_spawn_drill=run|join|park and --done-marker "[ATV-SPAWN-DRILL] client DONE". diff --git a/src/votv-coop/include/coop/dev/fall_drill.h b/src/votv-coop/include/coop/dev/fall_drill.h index a64f30459..2f809b6f1 100644 --- a/src/votv-coop/include/coop/dev/fall_drill.h +++ b/src/votv-coop/include/coop/dev/fall_drill.h @@ -1,8 +1,8 @@ // coop/dev/fall_drill.h -- [dev] a host birth that falls lands on the client where it lands on the host (L11). // HOST -- once slot 1's world is ready and its puppet stands, spawns a drive 2 m ahead and 4 m up of its own player and // says, once the drive is tracked and lies still, where it came to rest and how far it fell. -// CLIENT -- once its world is ready, takes the newest stream end applied here this session whose prop is a drive, -// and passes when its copy rests within 5 cm of the host's final pose the end carried. +// CLIENT -- once its world is ready, takes the one drive whose stream end landed here this session (the ends of two +// drives abandon) and passes when its copy rests within 5 cm of the host's final pose the end carried. // The red: prop_fall_no_coast=1 on the host streams no fall, so no end comes and the client fails. "[FALL-DRILL] // FAIL" is the lane failing (--fail-marker), "[FALL-DRILL] ABANDONED" the drill unable to do its part (--dead-marker). // Run with fall_drill=run and --done-marker "[FALL-DRILL] client DONE". diff --git a/src/votv-coop/include/coop/props/prop_drive_stream.h b/src/votv-coop/include/coop/props/prop_drive_stream.h index f2e91dad4..f9e163350 100644 --- a/src/votv-coop/include/coop/props/prop_drive_stream.h +++ b/src/votv-coop/include/coop/props/prop_drive_stream.h @@ -39,9 +39,10 @@ bool IsParked(void* actor); // Session end: every driven prop gets its physics back. Game thread. void OnDisconnect(); -// The stream ends applied here lately, newest first, at most `max` of the last eight: each eid, the -// host's final pose and the tick it landed (GetTickCount64). Cleared with the session. For the fall -// drill's verdict. Game thread. +// The stream ends applied here lately, newest first, at most `max` of the last kRecentEnds: each eid, +// the host's final pose and the tick it landed (GetTickCount64). Cleared with the session. For the fall +// drill's verdict, which polls it at 4 Hz: an end pushed out between two polls is lost to it. Game thread. +inline constexpr size_t kRecentEnds = 8; struct AppliedEnd { uint32_t eid = 0; ue_wrap::FVector pose{}; diff --git a/src/votv-coop/src/coop/dev/atv_spawn_drill.cpp b/src/votv-coop/src/coop/dev/atv_spawn_drill.cpp index 9db34f136..de4341b41 100644 --- a/src/votv-coop/src/coop/dev/atv_spawn_drill.cpp +++ b/src/votv-coop/src/coop/dev/atv_spawn_drill.cpp @@ -29,7 +29,7 @@ constexpr float kLiftCm = 50.f; constexpr uint64_t kAnnounceBound = 30000; // HOST: the spawn to the lane's announce constexpr uint64_t kMirrorBound = 60000; // CLIENT: world-ready to the mirror constexpr uint64_t kCheckEveryMs = 250; -constexpr int kOnceChecks = 4; // CLIENT: polls in a row the count must hold at one, a second +constexpr int kOnceChecks = 5; // CLIENT: polls in a row the count must hold at one: a second at 4 Hz enum class Step : uint8_t { Arm, Announce, Mirror, Done }; Step g_step = Step::Arm; diff --git a/src/votv-coop/src/coop/dev/fall_drill.cpp b/src/votv-coop/src/coop/dev/fall_drill.cpp index e0adcb1ba..2d47aa384 100644 --- a/src/votv-coop/src/coop/dev/fall_drill.cpp +++ b/src/votv-coop/src/coop/dev/fall_drill.cpp @@ -142,16 +142,22 @@ void ClientTick() { const uint64_t now = ::GetTickCount64(); if (now < g_nextCheckMs) return; g_nextCheckMs = now + kCheckEveryMs; - // The newest end since the baseline whose prop is a drive here: the host's dropped drive. - coop::prop_drive_stream::AppliedEnd ends[8]; - const size_t n = coop::prop_drive_stream::RecentEnds(ends, 8); + // The one drive whose end landed here since the baseline: the host's dropped drive. Ends of two drives + // cannot be told apart from here, so the drill does not choose between them. + namespace DS = coop::prop_drive_stream; + DS::AppliedEnd ends[DS::kRecentEnds]; + const size_t n = DS::RecentEnds(ends, DS::kRecentEnds); void* actor = nullptr; uint32_t eid = 0; ue_wrap::FVector pose{}; - for (size_t k = 0; k < n && !actor; ++k) { - if (ends[k].atMs <= g_baseMs) continue; + for (size_t k = 0; k < n; ++k) { + if (ends[k].atMs <= g_baseMs || ends[k].eid == eid) continue; void* a = coop::remote_prop::ResolveLiveActorByEid(ends[k].eid); - if (a && R::ClassNameOf(a) == kDriveCls) { actor = a; eid = ends[k].eid; pose = ends[k].pose; } + if (!a || R::ClassNameOf(a) != kDriveCls) continue; + if (actor) { Abandon("the ends of two drives landed here this session; the drill cannot tell the host's"); return; } + actor = a; + eid = ends[k].eid; + pose = ends[k].pose; } ue_wrap::FVector here{}; if (!actor || !E::TryGetActorLocation(actor, here)) { diff --git a/src/votv-coop/src/coop/dev/log_drill.cpp b/src/votv-coop/src/coop/dev/log_drill.cpp index 529f30374..9747ce619 100644 --- a/src/votv-coop/src/coop/dev/log_drill.cpp +++ b/src/votv-coop/src/coop/dev/log_drill.cpp @@ -31,6 +31,17 @@ unsigned long LiveWriter(const std::wstring& path) { return line ? std::strtoul(line + 9, nullptr, 10) : 0; } +// Whether `pid` names a running process: a log left by an exited one, still held open by a viewer, is not another +// writer. +bool Running(unsigned long pid) { + HANDLE p = ::OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, pid); + if (!p) return false; + DWORD code = 0; + const bool live = ::GetExitCodeProcess(p, &code) && code == STILL_ACTIVE; + ::CloseHandle(p); + return live; +} + } // namespace void RunOnce() { @@ -47,9 +58,10 @@ void RunOnce() { if (expected.size() > 4 && _wcsicmp(expected.c_str() + expected.size() - 4, L".log") == 0) expected.resize(expected.size() - 4); expected += L"." + std::to_wstring(me) + L".log"; const unsigned long other = LiveWriter(base); - if (_wcsicmp(mine.c_str(), expected.c_str()) != 0 || other == 0 || other == me) { - UE_LOGW("[LOG-DRILL] FAIL: process %lu writes '%ls' (expected '%ls'); the usual log names process %lu", - me, mine.c_str(), expected.c_str(), other); + const bool running = other != 0 && other != me && Running(other); + if (_wcsicmp(mine.c_str(), expected.c_str()) != 0 || !running) { + UE_LOGW("[LOG-DRILL] FAIL: process %lu writes '%ls' (expected '%ls'); the usual log names process %lu " + "(running=%d; 0 = its process line unread)", me, mine.c_str(), expected.c_str(), other, running ? 1 : 0); return; } UE_LOGI("[LOG-DRILL] DONE: process %lu writes its own '%ls'; the usual log is process %lu's -- PASS", me, diff --git a/src/votv-coop/src/coop/dev/sack_drill.cpp b/src/votv-coop/src/coop/dev/sack_drill.cpp index 811b511b4..eb776030c 100644 --- a/src/votv-coop/src/coop/dev/sack_drill.cpp +++ b/src/votv-coop/src/coop/dev/sack_drill.cpp @@ -46,6 +46,7 @@ constexpr uint64_t kReadyBoundMs = 3000; // CLIENT: the aim to the player's l constexpr uint64_t kCheckEveryMs = 250; constexpr uint64_t kSettleBoundMs = 10000; // CLIENT: the mirror's find to it at rest (it is spawned above the floor) constexpr int kRestChecks = 4; // ...that many reads in a row, a check apart, within a centimetre +constexpr int kReaimMax = 5; // CLIENT: times the trace may leave the sack before the drill gives up enum class Step : uint8_t { Arm, Find, Settle, Aim, Ready, Press, Seen, Done }; Step g_step = Step::Arm; @@ -60,6 +61,7 @@ coop::director::AimFan g_fan; ue_wrap::FVector g_lastAt{}; int g_restReads = 0; uint64_t g_nextReadMs = 0; +int g_reaims = 0; bool Enabled() { static const bool on = coop::config::ResolveString(::coop::config_registry::rows::sack_drill) == "run"; @@ -116,12 +118,20 @@ void* NearestSack(void* player) { auto* n = static_cast(c); ue_wrap::FVector at{}; if (R::NameStartsWith(R::NameOf(obj), L"Default__") || !E::TryGetActorLocation(obj, at)) return; - const float cm = std::hypot(at.X - n->from.X, at.Y - n->from.Y); + const float dx = at.X - n->from.X, dy = at.Y - n->from.Y, dz = at.Z - n->from.Z; + const float cm = std::sqrt(dx * dx + dy * dy + dz * dz); if (cm < n->bestCm) { n->best = obj; n->bestCm = cm; } }, &ctx); return ctx.best; } +// CLIENT: the trace left the sack; aim again, a bounded number of times. +void Reaim() { + if (++g_reaims > kReaimMax) { Abandon("the trace kept leaving the sack after the aim took it"); return; } + g_fan = coop::director::AimFan(); + Next(Step::Aim); +} + void HostTick(coop::net::Session* s) { if (g_step == Step::Done) return; const uint64_t now = ::GetTickCount64(); @@ -223,28 +233,26 @@ void ClientTick() { if (st == coop::director::AimFan::State::Aimed) Next(Step::Ready); return; } - case Step::Ready: // the trace on the sack, then the player's look settled on it (a later tick than the trace) - if (E::ReadMainPlayerHitActor(player) != g_sack.Get()) { // the trace left it: aim again - g_fan = coop::director::AimFan(); - Next(Step::Aim); - return; - } + case Step::Ready: { // the trace on the sack, then the player's look settled on it (a later tick than the trace) + void* sack = g_sack.Get(); + if (!sack) { Abandon("the sack's mirror died"); return; } + if (E::ReadMainPlayerHitActor(player) != sack) { Reaim(); return; } if (E::MainPlayerHasSelectedAction(player)) { Next(Step::Press); return; } if (Expired(kReadyBoundMs)) { - SayLookState(player, g_sack.Get()); + SayLookState(player, sack); Abandon("the player's look never settled on the sack"); } return; - case Step::Press: - if (E::ReadMainPlayerHitActor(player) != g_sack.Get()) { // the press goes to whatever the trace holds - g_fan = coop::director::AimFan(); - Next(Step::Aim); - return; - } + } + case Step::Press: { // the press goes to whatever the trace holds + void* sack = g_sack.Get(); + if (!sack) { Abandon("the sack's mirror died"); return; } + if (E::ReadMainPlayerHitActor(player) != sack) { Reaim(); return; } if (!E::CallMainPlayerUseSelectedAction(player)) { Abandon("useSelectedAction did not dispatch"); return; } UE_LOGI("[SACK-DRILL] client pressed the sack"); Next(Step::Seen); return; + } case Step::Seen: { int32_t now1 = 0; if (ue_wrap::economy::ReadPoints(&now1) && now1 > g_points0) { @@ -278,6 +286,7 @@ void OnDisconnect() { g_sack.Reset(); g_restReads = 0; g_nextReadMs = 0; + g_reaims = 0; g_lastAt = {}; } diff --git a/src/votv-coop/src/coop/interactables/atv_runtime_mirror.cpp b/src/votv-coop/src/coop/interactables/atv_runtime_mirror.cpp index 1bb3cf1f3..9b6013791 100644 --- a/src/votv-coop/src/coop/interactables/atv_runtime_mirror.cpp +++ b/src/votv-coop/src/coop/interactables/atv_runtime_mirror.cpp @@ -64,11 +64,11 @@ void OnAtvSpawn(const coop::net::AtvSpawnPayload& payload, uint8_t /*senderPeerS spawn_park::Park(synthKey, payload, "the receiving world is not ready"); return; } - // The ATV spawn drill's park arm: the first spawn is parked as if the world were not ready, so the park's - // hand-back is what spawns it. + // The ATV spawn drill's park arm: the first spawn reaching a ready world is parked as if it were not, so the + // park's hand-back is what spawns it. A hand-back is already the park's work and is let through. static const bool s_parkFirst = coop::config::ResolveFlag(coop::config_registry::rows::atv_park_first_spawn); static bool s_parkedFirst = false; - if (s_parkFirst && !s_parkedFirst) { + if (s_parkFirst && !s_parkedFirst && !spawn_park::Holds(synthKey)) { s_parkedFirst = true; spawn_park::Park(synthKey, payload, "the drill parks the first spawn"); return; diff --git a/src/votv-coop/src/coop/interactables/atv_spawn_park.cpp b/src/votv-coop/src/coop/interactables/atv_spawn_park.cpp index 1d6a1c097..793a8779a 100644 --- a/src/votv-coop/src/coop/interactables/atv_spawn_park.cpp +++ b/src/votv-coop/src/coop/interactables/atv_spawn_park.cpp @@ -52,6 +52,7 @@ void Park(const std::wstring& key, const coop::net::AtvSpawnPayload& payload, co } void Discard(const std::wstring& key) { g_parked.erase(key); } +bool Holds(const std::wstring& key) { return g_parked.count(key) != 0; } uint32_t ParkedEver() { return g_parkedEver; } size_t Pending() { return g_parked.size(); } diff --git a/src/votv-coop/src/coop/interactables/atv_spawn_park.h b/src/votv-coop/src/coop/interactables/atv_spawn_park.h index bb78aae6f..d27ff55e1 100644 --- a/src/votv-coop/src/coop/interactables/atv_spawn_park.h +++ b/src/votv-coop/src/coop/interactables/atv_spawn_park.h @@ -17,6 +17,8 @@ namespace coop::atv_sync::spawn_park { void Park(const std::wstring& key, const coop::net::AtvSpawnPayload& payload, const char* why); // A spawn that landed, was malformed, or whose ATV was destroyed: nothing for it waits any longer. void Discard(const std::wstring& key); +// Whether a spawn for this key waits here: a hand-back still holds its entry while OnAtvSpawn runs it. +bool Holds(const std::wstring& key); // Tick, inside a gameplay world: hand each parked spawn back to OnAtvSpawn, which parks it again while the world is // still not ready. A half-second poll of that readiness. void DrainReady(); diff --git a/src/votv-coop/src/coop/props/container_birth.cpp b/src/votv-coop/src/coop/props/container_birth.cpp index d08b5f4e7..cae618163 100644 --- a/src/votv-coop/src/coop/props/container_birth.cpp +++ b/src/votv-coop/src/coop/props/container_birth.cpp @@ -70,10 +70,8 @@ void Push(std::vector& births, void* actor, uint8_t authorSlot) { births.push_back(std::move(b)); } -} // namespace - -namespace { uint32_t g_endedGone = 0, g_endedExpired = 0; // the throw drill's counts; process-long, the drill baselines + } // namespace void NoteAuthoredBirth(void* actor) { diff --git a/src/votv-coop/src/coop/props/prop_drive_stream.cpp b/src/votv-coop/src/coop/props/prop_drive_stream.cpp index 58c9a6dce..31d11f957 100644 --- a/src/votv-coop/src/coop/props/prop_drive_stream.cpp +++ b/src/votv-coop/src/coop/props/prop_drive_stream.cpp @@ -123,8 +123,7 @@ bool YieldedAtOrAfter(uint32_t eid, uint8_t gen) { return y != g_yieldedGen.end() && !GenAfter(gen, y->second); } -// The last stream ends applied here, a ring of eight: a poll a quarter-second apart reads them all. -constexpr size_t kRecentEnds = 8; +// The last stream ends applied here, a ring of kRecentEnds (prop_drive_stream.h). AppliedEnd g_ends[kRecentEnds]; size_t g_endCount = 0; // ends recorded since the session began; the newest is at (g_endCount - 1) % kRecentEnds diff --git a/src/votv-coop/src/ue_wrap/engine/engine_mainplayer.cpp b/src/votv-coop/src/ue_wrap/engine/engine_mainplayer.cpp index b503be20f..1d7678217 100644 --- a/src/votv-coop/src/ue_wrap/engine/engine_mainplayer.cpp +++ b/src/votv-coop/src/ue_wrap/engine/engine_mainplayer.cpp @@ -297,8 +297,8 @@ bool MainPlayerHasSelectedAction(void* mainPlayer) { if (!f.valid() || !Call(mainPlayer, f)) return false; // asName is an out FString: three paths write it empty, the names list's path a copy of a name, which // the engine allocated and the frame now owns, so it is freed here. - struct { wchar_t* data; int32_t num; int32_t max; } name{}; - if (f.GetRaw(L"asName", &name, sizeof(name)) && name.data) R::EngineFree(name.data); + R::FString name{}; + if (f.GetRaw(L"asName", &name, sizeof(name)) && name.Data) R::EngineFree(name.Data); return f.Get(L"return"); } From 7ea6c54d5149d2cd680700137c38822df9ba9579 Mon Sep 17 00:00:00 2001 From: pelmentor Date: Sat, 10 Oct 2026 05:19:19 +0600 Subject: [PATCH 076/306] [l10n] a gettext catalogue for the mod's own text The shape is MTA's localisation: gettext catalogues, English as the msgid, T/Tc/Tn/Tcn lookups and a do-nothing marker for static tables. The reader, the plural evaluator and the formatter are ours, since its parser is GPL. A .po file is untrusted text: a bad entry is refused, a file that cannot be bounded is refused whole. Plural rules are evaluated, not matched against known strings; conversions are a closed set checked against the msgid, and Fmt reorders arguments without the CRT's positional printf. The harness picks the language from the ui.language row (auto: the Windows display language) and builds one catalogue before the overlay's first frame. No call site yet; the selftest checks the plural rules against an independent evaluator. Co-Authored-By: Claude Opus 5.5 --- .github/ci/l10n_vectors.py | 119 ++++++ .github/workflows/repo-gates.yml | 3 + docs/code-map.md | 10 + src/votv-coop/CMakeLists.txt | 18 + .../coop/config/config_registry_rows.inc | 4 + .../coop/config/config_registry_rows_dev.inc | 3 + src/votv-coop/include/l10n/l10n.h | 71 ++++ src/votv-coop/include/l10n/mark.h | 8 + src/votv-coop/include/l10n/plural_expr.h | 49 +++ src/votv-coop/include/l10n/po_reader.h | 54 +++ src/votv-coop/include/l10n/printf_check.h | 50 +++ src/votv-coop/sources.cmake | 8 + src/votv-coop/src/harness/harness.cpp | 5 + src/votv-coop/src/l10n/catalog.cpp | 77 ++++ src/votv-coop/src/l10n/catalog.h | 93 +++++ src/votv-coop/src/l10n/fmt.cpp | 173 +++++++++ src/votv-coop/src/l10n/l10n.cpp | 221 +++++++++++ src/votv-coop/src/l10n/l10n_selftest.cpp | 258 +++++++++++++ src/votv-coop/src/l10n/locale_choice.cpp | 73 ++++ src/votv-coop/src/l10n/locale_choice.h | 23 ++ src/votv-coop/src/l10n/plural_expr.cpp | 259 +++++++++++++ src/votv-coop/src/l10n/plural_vectors.inc | 99 +++++ src/votv-coop/src/l10n/po_reader.cpp | 348 ++++++++++++++++++ src/votv-coop/src/l10n/printf_check.cpp | 129 +++++++ 24 files changed, 2155 insertions(+) create mode 100644 .github/ci/l10n_vectors.py create mode 100644 src/votv-coop/include/l10n/l10n.h create mode 100644 src/votv-coop/include/l10n/mark.h create mode 100644 src/votv-coop/include/l10n/plural_expr.h create mode 100644 src/votv-coop/include/l10n/po_reader.h create mode 100644 src/votv-coop/include/l10n/printf_check.h create mode 100644 src/votv-coop/src/l10n/catalog.cpp create mode 100644 src/votv-coop/src/l10n/catalog.h create mode 100644 src/votv-coop/src/l10n/fmt.cpp create mode 100644 src/votv-coop/src/l10n/l10n.cpp create mode 100644 src/votv-coop/src/l10n/l10n_selftest.cpp create mode 100644 src/votv-coop/src/l10n/locale_choice.cpp create mode 100644 src/votv-coop/src/l10n/locale_choice.h create mode 100644 src/votv-coop/src/l10n/plural_expr.cpp create mode 100644 src/votv-coop/src/l10n/plural_vectors.inc create mode 100644 src/votv-coop/src/l10n/po_reader.cpp create mode 100644 src/votv-coop/src/l10n/printf_check.cpp diff --git a/.github/ci/l10n_vectors.py b/.github/ci/l10n_vectors.py new file mode 100644 index 000000000..b00aa3b39 --- /dev/null +++ b/.github/ci/l10n_vectors.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 +"""l10n_vectors -- the plural-rule test vectors the l10n selftest checks our evaluator against. + +A catalogue's Plural-Forms header is a C expression over n, and src/l10n/plural_expr.cpp evaluates +it. A vector is (rule, n, the form n takes); a vector whose expected form came from our own +evaluator would pass whatever that evaluator gets wrong, so the forms here come from Python's own +gettext.c2py, an implementation independent of ours. + +The rules are the distinct ones languages ship: the thirteen of MTA's 43 client catalogues (whitespace +aside), plus Polish, Irish and the three-form Czech rule of GNU gettext's manual. They are short +formulas, facts of each language, written here as data. Beside them sit headers the evaluator must +refuse. + +Usage: + l10n_vectors.py writes src/votv-coop/src/l10n/plural_vectors.inc + l10n_vectors.py --check exits 1 when that file differs from what this script writes +""" + +import gettext +import pathlib +import re +import sys + +OUT = pathlib.Path(__file__).resolve().parents[2] / "src" / "votv-coop" / "src" / "l10n" / "plural_vectors.inc" + +RULES = [ + "nplurals=1; plural=0;", + "nplurals=2; plural=(n != 1);", + "nplurals=2; plural=(n > 1);", + "nplurals=2; plural=(n%10==1 && n%100!=11 ? 0 : 1);", + "nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);", + "nplurals=3; plural=(n==1 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);", + "nplurals=3; plural=(n==1) ? 0 : (n>=2 && n<=4) ? 1 : 2;", + "nplurals=3; plural=n==1 ? 0 : n==2 ? 1 : 2;", + "nplurals=3; plural=n%10==1 && n%100!=11 ? 0 : n != 0 ? 1 : 2;", + "nplurals=3; plural=(n==1 ? 0 : (n==0 || (n%100 > 0 && n%100 < 20)) ? 1 : 2);", + "nplurals=4; plural=(n==1) ? 0 : (n>=2 && n<=4) ? 1 : 3;", + "nplurals=4; plural=n%100==1 ? 0 : n%100==2 ? 1 : n%100==3 || n%100==4 ? 2 : 3;", + "nplurals=4; plural=(n%10==1 && (n%100>19 || n%100<11) ? 0 : (n%10>=2 && n%10<=9) && " + "(n%100>19 || n%100<11) ? 1 : n%1!=0 ? 2 : 3);", + "nplurals=4; plural=(n==1 ? 0 : (n%10>=2 && n%10<=4) && (n%100<12 || n%100>14) ? 1 : " + "n!=1 && (n%10>=0 && n%10<=1) || (n%10>=5 && n%10<=9) || (n%100>=12 && n%100<=14) ? 2 : 3);", + "nplurals=4; plural=((n%10==1 && n%100!=11) ? 0 : ((n%10>=2 && n%10<=4 && (n%100<12 || n%100>14)) ? 1 : " + "((n%10==0 || (n%10>=5 && n%10<=9)) || (n%100>=11 && n%100<=14)) ? 2 : 3));", + "nplurals=6; plural=(n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 && n%100<=10 ? 3 : n%100>=11 && n%100<=99 ? 4 : 5);", +] + +N = [0, 1, 2, 3, 4, 5, 7, 11, 12, 14, 21, 22, 25, 100, 101, 102, 111, 1000000, 2 ** 63] + +REFUSED = [ + "nplurals=INTEGER; plural=EXPRESSION;", # a fresh template's placeholder + "nplurals=0; plural=0;", + "nplurals=9; plural=n;", + "nplurals=2;", + "nplurals=2; plural=n ^ 1;", # a token outside the grammar + "nplurals=2; plural=(n != 1;", + "nplurals=2; plural=n != 1 1;", # input left over + "nplurals=2; plural=" + "(" * 33 + "n" + ")" * 33 + ";", # nested deeper than 32 + "nplurals=2; plural=" + "!" * 33 + "n;", + "nplurals=2; plural=" + "n+" * 128 + "n;", # longer than 256 characters + "nplurals=2; plural=12345678901234567890;", # a number of more than 19 digits +] + + +def expression(rule): + m = re.search(r"plural\s*=\s*(.*?);?\s*$", rule) + return m.group(1).rstrip(";") + + +def c_string(s, indent=" ", width=110): + """A C string literal, split into adjacent literals that the compiler joins, to stay in width.""" + body = s.replace("\\", "\\\\").replace('"', '\\"') + room = width - len(indent) - 3 + chunks = [body[i:i + room] for i in range(0, len(body), room)] or [""] + return ("\n" + indent).join('"' + c + '"' for c in chunks) + + +def render(): + lines = [ + "// l10n/plural_vectors.inc -- GENERATED by .github/ci/l10n_vectors.py, never hand-edited. The", + "// forms are Python's gettext.c2py's, an evaluator independent of src/l10n/plural_expr.cpp.", + "", + "constexpr const char* kRules[] = {", + ] + lines += [" " + c_string(r) + "," for r in RULES] + lines += ["};", "", "struct Vector { unsigned char rule; unsigned long long n; unsigned char form; };", + "constexpr Vector kVectors[] = {"] + for i, r in enumerate(RULES): + f = gettext.c2py(expression(r)) + cells = ["{%d, %dull, %d}," % (i, n, f(n)) for n in N] + row = " " + for c in cells: + if len(row) + 1 + len(c) > 110: + lines.append(row) + row = " " + row += " " + c + lines.append(row) + lines += ["};", "", "constexpr const char* kRefusedRules[] = {"] + lines += [" " + c_string(r) + "," for r in REFUSED] + lines += ["};", ""] + return "\n".join(lines) + + +def main(): + text = render() + if "--check" in sys.argv[1:]: + have = OUT.read_text(encoding="utf-8") if OUT.exists() else "" + if have.replace("\r\n", "\n") != text: + print("l10n_vectors: %s is stale -- run .github/ci/l10n_vectors.py" % OUT.name) + return 1 + print("l10n_vectors: %s is current" % OUT.name) + return 0 + OUT.write_text(text, encoding="utf-8", newline="\n") + print("l10n_vectors: wrote %s (%d rules x %d n)" % (OUT.name, len(RULES), len(N))) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/workflows/repo-gates.yml b/.github/workflows/repo-gates.yml index 414b9e306..93bdb35bb 100644 --- a/.github/workflows/repo-gates.yml +++ b/.github/workflows/repo-gates.yml @@ -144,3 +144,6 @@ jobs: - name: ReliableKind routing gate (every wire kind reaches a receiver, or names why not) run: python3 .github/ci/reliablekind_gate.py + + - name: Plural vectors gate (the l10n selftest's expected forms are the independent evaluator's) + run: python3 .github/ci/l10n_vectors.py --check diff --git a/docs/code-map.md b/docs/code-map.md index 940e43808..ee47c9125 100644 --- a/docs/code-map.md +++ b/docs/code-map.md @@ -10,6 +10,7 @@ One folder, one concept, named after it. `src/votv-coop/src/` holds the implemen |---|---|---|---| | gameplay and network | `coop/` | elements, sync lanes, sessions, players, the wire protocol | reflection or engine-memory access (it goes through `ue_wrap/`) | | what the player sees | `ui/` | the menus, the server browser, the HUD, the F1 overlay | network state | +| the player's language | `l10n/` | the catalogues of the mod's own text, the lookups, the formatter | anything of `coop/` or `ui/` (they call it) | | boot glue and tests | `harness/` | the scenario runner and the scripted test scenarios | gameplay logic | | engine wrapper | `ue_wrap/` | reflection, signatures, hooks, the game-thread pump, one wrapper per engine or game class | network, gameplay or co-op state | | the loader contract | `loader/`, `bootstrap/` | `start_mod()` as UE4SS calls it, `DllMain`, the refuse dialog | everything else | @@ -52,6 +53,15 @@ One folder, one concept, named after it. `src/votv-coop/src/` holds the implemen | the F1 overlay | `imgui_overlay`, `overlay_backend` with `overlay_backend_dx11`, `overlay_backend_dx12`, `overlay_backend_dx12_capture`, `overlay_cursor`, `overlay_diag`, `overlay_test_arm`, `fonts`, `atlas_watch`, `input_focus` | | overlay panels | `dev_menu`, `admin_panel`, `skins_panel`, `voice_panel`, `world_rules_panel`, `server_settings_pane`, `bug_report_pane`, `config_review_panel`, `net_stats_panel`, `console` | +## `l10n/` — the player's language + +`l10n` (the lookups `T`, `Tc`, `Tn`, `Tcn`, `Label`, `Fmt`, and `Init`, which builds the one catalogue), +`po_reader` (a gettext `.po` file read, the only parser of a translator's text), `plural_expr` (a catalogue's +plural rule, compiled once), `printf_check` (the conversions a message may carry), `fmt` (the positional +formatter), `catalog` and `locale_choice` (private to the folder), `l10n_selftest`. The packs the mod ships +are `src/votv-coop/locale/.po`, embedded by name; `.github/ci/l10n_vectors.py` writes the selftest's +plural vectors. + ## `harness/` — boot glue and scripted tests `harness` (the scenario runner), `session_runtime`, `autotest` + `autotest_dispatch` and `harness/autotest/` diff --git a/src/votv-coop/CMakeLists.txt b/src/votv-coop/CMakeLists.txt index 0cfa4db92..a4c76dd8b 100644 --- a/src/votv-coop/CMakeLists.txt +++ b/src/votv-coop/CMakeLists.txt @@ -263,6 +263,24 @@ target_sources(votv-coop PRIVATE ${CMAKE_CURRENT_BINARY_DIR}/generated/version.r # scan follows #include and not a data file, so the edge is stated: editing a name rebuilds the embed. set_source_files_properties(resources/thanks.rc PROPERTIES OBJECT_DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/assets/thanks/thanks.txt) +# The language packs: every locale/.po embedded as the RCDATA resource L10N_, which +# l10n::Init finds by name. The .rc is written here from the folder's files and rewritten only when the +# list changes; each .po is a stated dependency edge, since the resource compiler scans no data file. +file(GLOB VOTVCOOP_L10N_PACKS CONFIGURE_DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/locale/*.po) +set(VOTVCOOP_L10N_RC_TEXT "// Generated by CMakeLists.txt from locale/*.po -- never edited by hand.\n") +foreach(pack ${VOTVCOOP_L10N_PACKS}) + get_filename_component(locale ${pack} NAME_WE) + string(TOUPPER ${locale} locale_upper) + string(APPEND VOTVCOOP_L10N_RC_TEXT "L10N_${locale_upper} RCDATA \"${pack}\"\n") +endforeach() +file(WRITE ${CMAKE_CURRENT_BINARY_DIR}/generated/l10n_packs.rc.tmp "${VOTVCOOP_L10N_RC_TEXT}") +configure_file(${CMAKE_CURRENT_BINARY_DIR}/generated/l10n_packs.rc.tmp + ${CMAKE_CURRENT_BINARY_DIR}/generated/l10n_packs.rc COPYONLY) +target_sources(votv-coop PRIVATE ${CMAKE_CURRENT_BINARY_DIR}/generated/l10n_packs.rc) +if(VOTVCOOP_L10N_PACKS) + set_source_files_properties(${CMAKE_CURRENT_BINARY_DIR}/generated/l10n_packs.rc PROPERTIES + OBJECT_DEPENDS "${VOTVCOOP_L10N_PACKS}") +endif() # version.lib: read the running exe's file version in the health check. # minhook: our inline-hook substrate (game-thread dispatcher). # ws2_32: Winsock for the UDP coop transport (src/coop/net). diff --git a/src/votv-coop/include/coop/config/config_registry_rows.inc b/src/votv-coop/include/coop/config/config_registry_rows.inc index 62ebf7bfd..685976b6f 100644 --- a/src/votv-coop/include/coop/config/config_registry_rows.inc +++ b/src/votv-coop/include/coop/config/config_registry_rows.inc @@ -176,6 +176,10 @@ CFG_FLAG(ui_netstats, "ui.netstats", "ui", false, nullptr, "Show the network statistics overlay.") CFG_FLAG(ui_chat_peer_actions, "ui.chat.peer_actions", "ui", true, nullptr, "Show peer action lines (joins, pickups, device use) in the chat feed.") +CFG_STRING(ui_language, "ui.language", "ui", "auto", "VOTVCOOP_UI_LANGUAGE", false, + "The language of the mod's own interface: auto follows the Windows display language, or name " + "a locale such as zh_CN. A language without a catalogue shows English. Takes effect at the " + "next start.") CFG_STRING(browser_lastdirect, "browser.lastdirect", "ui", ::coop::net::kDefaultDirectAddr, nullptr, false, "The last address typed into the server browser's direct-connect box (remembered " "automatically). Its default names the port a host actually listens on (net.port), " diff --git a/src/votv-coop/include/coop/config/config_registry_rows_dev.inc b/src/votv-coop/include/coop/config/config_registry_rows_dev.inc index 05ad5042d..d39b18387 100644 --- a/src/votv-coop/include/coop/config/config_registry_rows_dev.inc +++ b/src/votv-coop/include/coop/config/config_registry_rows_dev.inc @@ -722,6 +722,9 @@ CFG_ENUM(auth_drill, "auth_drill", "dev", "off", "off|corrupt|silent|mismatch", "DIFFERENT host than the one that answered, which THIS peer must refuse itself " "-- it is the only arm whose refusal is ours, not the host's. " "Drill only.") +CFG_FLAG(l10n_selftest_red, "l10n_selftest_red", "dev", false, "VOTVCOOP_L10N_SELFTEST_RED", + "The l10n selftest's red arm: one expected plural form is made wrong, so the selftest says FAIL " + "and the rig's verdict reads it. Changes nothing else.") CFG_FLAG(atlas_no_exclude_drill, "atlas_no_exclude_drill", "dev", false, "VOTVCOOP_ATLAS_NO_EXCLUDE", "Drop GlyphExcludeRanges from every font source, so the faces bake their whole " diff --git a/src/votv-coop/include/l10n/l10n.h b/src/votv-coop/include/l10n/l10n.h new file mode 100644 index 000000000..cb2e2fbce --- /dev/null +++ b/src/votv-coop/include/l10n/l10n.h @@ -0,0 +1,71 @@ +// l10n/l10n.h -- the mod's own interface text in the player's language. +// +// The shape is MTA's (Client/core/CLocalization.cpp, Client/sdk/core/CLocalizationInterface.h): gettext +// catalogues, English as the msgid, four lookups -- T is `_`, Tc `_tc` (with a context), Tn `_tn` +// (plural), Tcn `_tcn` -- and a marker that does nothing at run time so the template extractor finds a +// string in a static table, translated where it is drawn (`_td`, here L10N_MARK). The reader, the plural +// evaluator and the formatter are ours: MTA's parser, tinygettext, is GPL and is not vendored. +// +// Keys are narrow UTF-8 literals and every call is qualified `l10n::` (the extractor's rule). A lookup +// returns the catalogue's own immutable storage, or its argument on a miss -- Tn's miss is +// `n == 1 ? singular : plural`, the English rule -- with one hash, no allocation and no lock. The +// catalogue is built once by Init, before the overlay's first frame, and never changes: a change of +// language takes effect at the next start. Before Init every lookup misses. Any thread. +#pragma once + +#include "l10n/mark.h" + +#include + +namespace l10n { + +const char* T (const char* msgid); +const char* Tc (const char* ctx, const char* msgid); +const char* Tn (const char* singular, const char* plural, unsigned long long n); +const char* Tcn(const char* ctx, const char* singular, const char* plural, unsigned long long n); + +// "###" for an id-bearing ImGui widget, composed into its own buffer: a temporary that lives +// to the end of the full expression, so `ImGui::Button(l10n::Label(l10n::T("Apply"), "apply"))` is +// safe. The id is a literal and never depends on the language, so a translated label neither changes a +// widget's id nor collides with another translated alike. Cut at a UTF-8 boundary; nothing interned. +class Label { +public: + Label(const char* text, const char* id); + operator const char*() const { return buf_; } + +private: + char buf_[256]; +}; + +// Byte offsets of an argument's text inside a formatted line; begin -1 when it is absent. +struct Span { + int begin = -1; + int len = 0; +}; + +// printf with argument numbers (`%2$s %1$s`), over the conversion grammar of l10n/printf_check.h. +// Returns the bytes written, cut at the last whole UTF-8 sequence that fits and always terminated, or +// -1 with an empty buffer when the format is refused. FmtSpan also reports where argument 1 landed. +int Fmt (char* buf, size_t size, const char* fmt, ...); +int FmtSpan(char* buf, size_t size, Span* arg1, const char* fmt, ...); + +// The locale chosen ("zh_CN") when any catalogue of it -- zh_CN or zh, pack or override -- contributed +// an entry, or "" when the interface is in English. +const char* ActiveLocale(); + +// Once, from the harness, before the overlay's first frame: `language` is the ui.language row's value, +// "auto" for the Windows display language. Builds the one merged catalogue (embedded pack and override +// files) and says what it chose and why. +void Init(const char* language); + +// The pure selftest, from the harness before Init: prints "l10n selftest: ALL PASS (N checks)" or each +// failing case. `injectRed` (the dev row l10n_selftest_red) makes one expected plural form wrong, so the +// rig's verdict is seen reading a failure. +bool RunSelftest(bool injectRed); + +// Dev, for the drill: whether a lookup of this entry has found its translation (ctx may be null), and +// how many entries have. Both read the flags a found lookup sets; neither marks anything. +bool WasFound(const char* ctx, const char* msgid); +size_t FoundCount(); + +} // namespace l10n diff --git a/src/votv-coop/include/l10n/mark.h b/src/votv-coop/include/l10n/mark.h new file mode 100644 index 000000000..66c4534a0 --- /dev/null +++ b/src/votv-coop/include/l10n/mark.h @@ -0,0 +1,8 @@ +// l10n/mark.h -- the markers that do nothing at run time: a literal in a static table (a config row's +// label, an F1 tree name) wrapped so the template extractor finds it, translated where it is drawn. +// MTA's `_td` (Client/sdk/core/CLocalizationInterface.h). Alone in its header so a table's owner, such +// as coop/config, includes nothing else of l10n. +#pragma once + +#define L10N_MARK(s) s +#define L10N_MARK_C(ctx, s) s diff --git a/src/votv-coop/include/l10n/plural_expr.h b/src/votv-coop/include/l10n/plural_expr.h new file mode 100644 index 000000000..f01203fc6 --- /dev/null +++ b/src/votv-coop/include/l10n/plural_expr.h @@ -0,0 +1,49 @@ +// l10n/plural_expr.h -- a catalogue's plural rule: the `Plural-Forms` header compiled once, at load. +// +// The header's C expression over `n` picks which of a translation's forms a count takes, and a +// language's rule is whatever its translators wrote there, so it is EVALUATED, as libintl evaluates +// it, rather than matched against a table of known strings -- tinygettext's table +// (vendor/tinygettext/plural_forms.cpp:50-85) misses the rule of 12 of MTA's own 43 locales. The +// grammar is GNU gettext's (plural.y): `n`, decimal integers, `( )`, `!`, `* / %`, `+ -`, +// `< > <= >=`, `== !=`, `&&`, `||`, `?:`, with C's precedence and associativity, over unsigned 64-bit +// values. The text is untrusted translator input: it is bounded in length and nesting, and compiled +// into a node array that Select walks, so no text is parsed per lookup. +#pragma once + +#include +#include +#include +#include + +namespace l10n::plural { + +inline constexpr size_t kMaxExprChars = 256; // longer is refused with the header +inline constexpr unsigned kMaxNesting = 32; // each `(`, `!` and `?:` branch is one level +inline constexpr unsigned kMaxForms = 8; // nplurals above this is refused + +class Rule { +public: + // Compiles a `Plural-Forms` header value ("nplurals=3; plural=(n%10==1 ? 0 : ...);"). False with + // the reason in `why` when it does not parse; the rule is then empty. + bool Compile(std::string_view headerValue, std::string* why); + + // The form `n` takes: always below Count(). A division or modulo by zero in this evaluation, + // or a result at or above Count(), is form 0, as libintl resets an out-of-range index. + unsigned Select(uint64_t n) const; + + unsigned Count() const { return nplurals_; } + bool Valid() const { return nplurals_ != 0; } + +private: + struct Node { + uint8_t op = 0; + int32_t a = -1, b = -1, c = -1; // operand node indices + uint64_t value = 0; // a literal's value + }; + friend class Parser; + std::vector nodes_; + int32_t root_ = -1; + unsigned nplurals_ = 0; +}; + +} // namespace l10n::plural diff --git a/src/votv-coop/include/l10n/po_reader.h b/src/votv-coop/include/l10n/po_reader.h new file mode 100644 index 000000000..f69abfa71 --- /dev/null +++ b/src/votv-coop/include/l10n/po_reader.h @@ -0,0 +1,54 @@ +// l10n/po_reader.h -- a gettext `.po` file, from its bytes to its entries and what was refused. +// +// The only parser of translator input, which is untrusted text: a pure function, no engine and no +// I/O, so the selftest drives it on in-memory files. The token set is gettext's: `msgctxt`, `msgid`, +// `msgid_plural`, `msgstr`, `msgstr[n]`, adjacent string lines concatenated, comment lines ignored, a +// `#, fuzzy` entry skipped (a line a translator has not confirmed -- msgfmt skips it too, where +// tinygettext reads it: dictionary_manager.cpp:45), the header read for its plural rule, charset and +// translators. A defect in one entry refuses that entry; a defect no entry boundary can contain (a +// string that never closes, a charset we do not read, bytes that are not UTF-8, a file over 1 MiB) +// refuses the file. An unknown escape refuses its entry where tinygettext warns and keeps it +// (po_parser.cpp:126-139): what a translator meant by it is not ours to guess. +#pragma once + +#include +#include +#include + +namespace l10n::po { + +inline constexpr size_t kMaxFileBytes = 1u << 20; +inline constexpr size_t kMaxEntryBytes = 16u << 10; +inline constexpr size_t kMaxForms = 8; + +struct Entry { + bool hasCtx = false; + std::string ctx; // msgctxt + std::string id; // msgid + bool plural = false; + std::string idPlural; // msgid_plural + std::vector str; // msgstr, or msgstr[0..n-1] + int line = 0; // the msgid's line +}; + +struct Diag { + int line = 0; + std::string what; +}; + +struct File { + bool ok = false; // false: the whole file is refused, `refusal` says why + Diag refusal; + std::string pluralForms; // the header's Plural-Forms value, "" if absent + std::string language, lastTranslator, languageTeam; + bool charsetAssumed = false; // no charset, or the template's CHARSET placeholder: read as UTF-8 + std::vector entries; // translated entries only: no header, fuzzy, empty or refused one + std::vector refused; // each refused entry, with its line +}; + +// Reads a whole file. An entry is translated when its msgstr (every msgstr[n] of a plural entry) is +// not empty; its conversions are checked against the msgid's here (l10n/printf_check.h), its count of +// plural forms against a rule by the catalogue, which knows which rule applies. +File Read(std::string_view bytes); + +} // namespace l10n::po diff --git a/src/votv-coop/include/l10n/printf_check.h b/src/votv-coop/include/l10n/printf_check.h new file mode 100644 index 000000000..1d6300cae --- /dev/null +++ b/src/votv-coop/include/l10n/printf_check.h @@ -0,0 +1,50 @@ +// l10n/printf_check.h -- the printf conversions a message may carry, read once by the catalogue's +// load (a translation against its msgid) and by l10n::Fmt (a format before it is written). +// +// A translation is untrusted text that reaches a formatter, so its conversions are a closed set: +// `%[n$][flags][width][.prec][length]type`, flags from `-+ #0` where C defines them for the type, +// width and precision of at most two decimal digits (no precision on `s`, which counts bytes and would +// cut a name), length `l` `ll` `z` with an integer type only, types `d i u x X o f F e E g G s`, and +// `%%`. `*` (an argument no call site passes), `%n` (a write through a pointer), `%c` (a NUL or half a +// UTF-8 sequence), wide `%ls` and every other length are refused. Numbering is all-unnumbered (the +// msgid's order) or all-numbered covering 1..k each exactly once: the CRT's positional printf +// terminates the process on a mixed format, a gap or `%n` (measured on the UCRT), and l10n::Fmt does +// not call it, but a format either formatter would choke on never loads. +#pragma once + +#include +#include +#include + +namespace l10n::printf_check { + +inline constexpr size_t kMaxConvs = 16; + +enum class Len : uint8_t { None, L, LL, Z }; + +struct Conv { + uint16_t begin = 0, end = 0; // the conversion's bytes in the format, `%` to its type + uint16_t specBegin = 0; // the first byte after `%` and any `n$`: flags onward + uint8_t arg = 0; // 1-based argument number + char type = 0; // d i u x X o f F e E g G s + Len len = Len::None; +}; + +struct Parsed { + std::array convs{}; // in format order; `%%` is not one + uint8_t count = 0; // conversions in the format + uint8_t args = 0; // arguments they read (== count: each exactly once) +}; + +// Reads `fmt` with the grammar above. False with the reason in `why`; nothing allocates. +bool Parse(std::string_view fmt, Parsed* out, const char** why); + +// A translation's conversions against its msgid's: the same argument count, and for each +// argument number the same type and length (`d` and `i` are one type); flags, width and precision +// may differ. False with the reason. +bool Matches(const Parsed& msgid, const Parsed& translation, const char** why); + +// The conversion that reads argument `arg`, or null. +const Conv* ForArg(const Parsed& p, uint8_t arg); + +} // namespace l10n::printf_check diff --git a/src/votv-coop/sources.cmake b/src/votv-coop/sources.cmake index 81fdfff97..55771f42c 100644 --- a/src/votv-coop/sources.cmake +++ b/src/votv-coop/sources.cmake @@ -793,4 +793,12 @@ set(VOTVCOOP_SOURCES src/harness/screenshot.cpp src/harness/mod_environment.cpp src/harness/sdk_check.cpp + src/l10n/po_reader.cpp + src/l10n/plural_expr.cpp + src/l10n/printf_check.cpp + src/l10n/fmt.cpp + src/l10n/catalog.cpp + src/l10n/locale_choice.cpp + src/l10n/l10n.cpp + src/l10n/l10n_selftest.cpp ) diff --git a/src/votv-coop/src/harness/harness.cpp b/src/votv-coop/src/harness/harness.cpp index a5407da17..84da0bf5d 100644 --- a/src/votv-coop/src/harness/harness.cpp +++ b/src/votv-coop/src/harness/harness.cpp @@ -47,6 +47,7 @@ #include "coop/player/remote_player.h" #include "coop/session/server_settings_sync.h" #include "coop/session/shutdown.h" +#include "l10n/l10n.h" #include "ui/dev_menu.h" #include "ui/imgui_overlay.h" #include "ui/console.h" @@ -540,6 +541,10 @@ void Start() { coop::voice_chat::SubscribeRows(); coop::server_settings_sync::BindSession(session_runtime::Session()); coop::server_settings_sync::SubscribeRows(); + // The interface language, chosen before the overlay's first frame and fixed for the process: the + // selftest first, pure, then the catalogue built once from the ui.language row. + l10n::RunSelftest(cfg::ResolveFlag(coop::config_registry::rows::l10n_selftest_red)); + l10n::Init(cfg::ResolveString(coop::config_registry::rows::ui_language).c_str()); if (!ui::imgui_overlay::Init()) { UE_LOGW("harness: imgui_overlay::Init failed -- F1 menu unavailable this run"); } diff --git a/src/votv-coop/src/l10n/catalog.cpp b/src/votv-coop/src/l10n/catalog.cpp new file mode 100644 index 000000000..6a4a806f7 --- /dev/null +++ b/src/votv-coop/src/l10n/catalog.cpp @@ -0,0 +1,77 @@ +// l10n/catalog.cpp -- see l10n/catalog.h. + +#include "catalog.h" + +namespace l10n::detail { + +int Catalog::Insert(const po::File& f, int fallbackRule, SourceReport& report) { + report.translator = f.lastTranslator; + report.refused.insert(report.refused.end(), f.refused.begin(), f.refused.end()); + int rule = fallbackRule; + if (!f.pluralForms.empty()) { + plural::Rule r; + std::string why; + if (r.Compile(f.pluralForms, &why)) { + rules_.push_back(std::move(r)); + rule = static_cast(rules_.size() - 1); + } else { + report.pluralNote = why; + } + } else { + report.pluralNote = "no Plural-Forms in the header"; + } + for (const po::Entry& e : f.entries) { + if (e.plural) { + // Each plural entry keeps the rule of the file it came from, so a pack's entry is never + // indexed by an override's rule. + if (rule < 0) { + report.refused.push_back(po::Diag{e.line, "a plural entry whose file has no usable Plural-Forms"}); + continue; + } + if (e.str.size() != rules_[static_cast(rule)].Count()) { + report.refused.push_back(po::Diag{e.line, "a count of msgstr[n] other than the rule's nplurals"}); + continue; + } + } + std::string key; + if (e.hasCtx) { + key.reserve(e.ctx.size() + 1 + e.id.size()); + key.append(e.ctx).push_back('\x04'); + } + key.append(e.id); + auto entry = std::make_unique(); + entry->plural = e.plural; + entry->forms = e.str; + entry->rule = static_cast(e.plural ? rule : 0); + map_[std::move(key)] = std::move(entry); + ++report.inserted; + } + report.loaded = true; + return rule; +} + +const Entry* Catalog::Peek(const char* ctx, std::string_view id) const { + const auto it = map_.find(KeyView{ctx, id}); + return it == map_.end() ? nullptr : it->second.get(); +} + +const Entry* Catalog::Find(const char* ctx, std::string_view id) const { + const Entry* e = Peek(ctx, id); + // One relaxed load per found lookup; the store happens once per entry. + if (e && !e->hit.load(std::memory_order_relaxed)) e->hit.store(1, std::memory_order_relaxed); + return e; +} + +unsigned Catalog::SelectForm(const Entry& e, unsigned long long n) const { + if (!e.plural || e.rule >= rules_.size()) return 0; + return rules_[e.rule].Select(n); +} + +size_t Catalog::FoundCount() const { + size_t n = 0; + for (const auto& kv : map_) + if (kv.second->hit.load(std::memory_order_relaxed)) ++n; + return n; +} + +} // namespace l10n::detail diff --git a/src/votv-coop/src/l10n/catalog.h b/src/votv-coop/src/l10n/catalog.h new file mode 100644 index 000000000..181d557f7 --- /dev/null +++ b/src/votv-coop/src/l10n/catalog.h @@ -0,0 +1,93 @@ +// l10n/catalog.h -- one language's merged catalogue (private to src/l10n/). +// +// Built once by l10n::Init from up to four sources inserted in rising priority -- the `ll` pack, the +// `ll` override, the `ll_CC` pack, the `ll_CC` override -- so a later insert replaces an earlier one and +// a lookup is one probe whatever the layering. Immutable once published; any thread may look up. +#pragma once + +#include "l10n/plural_expr.h" +#include "l10n/po_reader.h" + +#include +#include +#include +#include +#include +#include + +namespace l10n::detail { + +struct Entry { + bool plural = false; + std::vector forms; // one for a singular entry + uint8_t rule = 0; // the compiled rule of the file it came from + mutable std::atomic hit{0}; +}; + +// gettext's own key: "ctx\x04msgid" with a context, "msgid" without, looked up without building it. +struct KeyView { + const char* ctx; // null: no context + std::string_view id; +}; + +struct KeyHash { + using is_transparent = void; + static size_t Mix(size_t h, std::string_view s) noexcept { + for (unsigned char c : s) { h ^= c; h *= 1099511628211ull; } + return h; + } + size_t operator()(std::string_view key) const noexcept { return Mix(1469598103934665603ull, key); } + size_t operator()(const KeyView& k) const noexcept { + size_t h = 1469598103934665603ull; + if (k.ctx) { + h = Mix(h, k.ctx); + h = Mix(h, std::string_view("\x04", 1)); + } + return Mix(h, k.id); + } +}; + +struct KeyEq { + using is_transparent = void; + bool operator()(std::string_view a, std::string_view b) const noexcept { return a == b; } + bool operator()(std::string_view key, const KeyView& k) const noexcept { + if (!k.ctx) return key == k.id; + const std::string_view ctx(k.ctx); + return key.size() == ctx.size() + 1 + k.id.size() && key.compare(0, ctx.size(), ctx) == 0 && + key[ctx.size()] == '\x04' && key.compare(ctx.size() + 1, k.id.size(), k.id) == 0; + } + bool operator()(const KeyView& k, std::string_view key) const noexcept { return (*this)(key, k); } +}; + +// What one source contributed, for the log line. +struct SourceReport { + std::string name; // "embedded pack zh_CN", "override zh.po" + bool loaded = false; + std::string refusal; // why the whole file was refused, when it was + size_t inserted = 0; + std::vector refused; // the reader's refused entries and the catalogue's + std::string pluralNote; // why this file's Plural-Forms did not parse, when it did not + std::string translator; // Last-Translator +}; + +class Catalog { +public: + // Inserts a read file's entries over what is there. `fallbackRule` is the rule a plural entry takes + // when its own file has no parseable Plural-Forms (an override's: the pack's at its level), or -1. + // Returns the index of the rule this file's plural entries use, or -1 when it has none. + int Insert(const po::File& f, int fallbackRule, SourceReport& report); + + // A lookup: marks the entry found (its hit flag), as a drawn line does. + const Entry* Find(const char* ctx, std::string_view id) const; + // The same probe without marking, for the drill's reading. + const Entry* Peek(const char* ctx, std::string_view id) const; + unsigned SelectForm(const Entry& e, unsigned long long n) const; + size_t Size() const { return map_.size(); } + size_t FoundCount() const; + +private: + std::unordered_map, KeyHash, KeyEq> map_; + std::vector rules_; +}; + +} // namespace l10n::detail diff --git a/src/votv-coop/src/l10n/fmt.cpp b/src/votv-coop/src/l10n/fmt.cpp new file mode 100644 index 000000000..6ab963404 --- /dev/null +++ b/src/votv-coop/src/l10n/fmt.cpp @@ -0,0 +1,173 @@ +// l10n/fmt.cpp -- l10n::Fmt and FmtSpan (declared in l10n/l10n.h): our own positional formatter. +// +// MTA formats a translated string with SString::Printf, which has no argument numbers; a translation +// here may reorder its arguments, and the chat feed must know where the actor's name landed to colour +// it. The CRT's positional `_vsprintf_p` would reorder, but it terminates the process on a mixed +// format, a numbering gap or `%n` (measured on the UCRT) and reports no spans. So the format is read +// with printf_check's grammar, the arguments are taken from the va_list in their number order by the +// types the format names, and each conversion is written by snprintf from its own unnumbered spec. + +#include "l10n/l10n.h" +#include "l10n/printf_check.h" + +#include "ue_wrap/core/log.h" + +#include +#include +#include +#include + +namespace l10n { +namespace { + +namespace pc = printf_check; + +union Slot { + long long i; + unsigned long long u; + double d; + const char* s; +}; + +// The bytes kept end on a whole UTF-8 sequence: a cut inside one would leave an invalid tail. +size_t CutUtf8(const char* p, size_t n) { + size_t k = n; + while (k > 0 && (static_cast(p[k - 1]) & 0xC0) == 0x80) --k; // continuation bytes + if (k == 0) return 0; + const unsigned char lead = static_cast(p[k - 1]); + const size_t need = lead < 0x80 ? 1 : (lead >> 5) == 0x6 ? 2 : (lead >> 4) == 0xE ? 3 : 4; + return (n - (k - 1) >= need) ? n : k - 1; +} + +void Read(const pc::Conv& c, va_list& ap, Slot& s) { + switch (c.type) { + case 'd': case 'i': + switch (c.len) { + case pc::Len::None: s.i = va_arg(ap, int); break; + case pc::Len::L: s.i = va_arg(ap, long); break; + case pc::Len::LL: s.i = va_arg(ap, long long); break; + case pc::Len::Z: s.i = static_cast(va_arg(ap, ptrdiff_t)); break; + } + break; + case 'u': case 'x': case 'X': case 'o': + switch (c.len) { + case pc::Len::None: s.u = va_arg(ap, unsigned int); break; + case pc::Len::L: s.u = va_arg(ap, unsigned long); break; + case pc::Len::LL: s.u = va_arg(ap, unsigned long long); break; + case pc::Len::Z: s.u = static_cast(va_arg(ap, size_t)); break; + } + break; + case 's': s.s = va_arg(ap, const char*); break; + default: s.d = va_arg(ap, double); break; // f F e E g G + } +} + +// One conversion, from its own spec without the argument number, into out[0..room). +int WriteOne(const char* fmt, const pc::Conv& c, const Slot& s, char* out, size_t room) { + char spec[16]; + size_t n = 0; + spec[n++] = '%'; + for (size_t k = c.specBegin; k < c.end && n + 1 < sizeof(spec); ++k) spec[n++] = fmt[k]; + spec[n] = '\0'; + switch (c.type) { + case 'd': case 'i': + switch (c.len) { + case pc::Len::None: return std::snprintf(out, room, spec, static_cast(s.i)); + case pc::Len::L: return std::snprintf(out, room, spec, static_cast(s.i)); + case pc::Len::LL: return std::snprintf(out, room, spec, s.i); + case pc::Len::Z: return std::snprintf(out, room, spec, static_cast(s.i)); + } + return 0; + case 'u': case 'x': case 'X': case 'o': + switch (c.len) { + case pc::Len::None: return std::snprintf(out, room, spec, static_cast(s.u)); + case pc::Len::L: return std::snprintf(out, room, spec, static_cast(s.u)); + case pc::Len::LL: return std::snprintf(out, room, spec, s.u); + case pc::Len::Z: return std::snprintf(out, room, spec, static_cast(s.u)); + } + return 0; + case 's': return std::snprintf(out, room, spec, s.s ? s.s : "(null)"); + default: return std::snprintf(out, room, spec, s.d); + } +} + +int Format(char* buf, size_t size, Span* arg1, const char* fmt, va_list ap) { + if (arg1) *arg1 = Span{}; + if (!buf || size == 0) return -1; + buf[0] = '\0'; + if (!fmt) return -1; + pc::Parsed p; + const char* why = nullptr; + const size_t flen = std::strlen(fmt); + if (!pc::Parse(std::string_view(fmt, flen), &p, &why)) { + // Translations are checked at load and our English by the template gate, so this is a + // defect in a literal: said once, never per frame. + static std::atomic s_said{false}; + if (!s_said.exchange(true)) + UE_LOGE("l10n: a format the conversion grammar refuses (%s): '%.80s'", why ? why : "?", fmt); + return -1; + } + Slot slots[pc::kMaxConvs + 1] = {}; + for (uint8_t a = 1; a <= p.args; ++a) Read(*pc::ForArg(p, a), ap, slots[a]); + + size_t pos = 0; + const size_t cap = size - 1; // the terminator's byte + bool cut = false; + auto put = [&](const char* src, size_t n) { + if (cut) return; + if (n > cap - pos) { n = cap - pos; cut = true; } + std::memcpy(buf + pos, src, n); + pos += n; + }; + size_t at = 0; + uint8_t k = 0; + while (at < flen && !cut) { + if (fmt[at] == '%' && at + 1 < flen && fmt[at + 1] == '%') { put("%", 1); at += 2; continue; } + if (k < p.count && at == p.convs[k].begin) { + const pc::Conv& c = p.convs[k++]; + const size_t start = pos; + const int w = WriteOne(fmt, c, slots[c.arg], buf + pos, cap - pos + 1); + if (w < 0) { cut = true; break; } + if (static_cast(w) > cap - pos) { pos = cap; cut = true; } + else pos += static_cast(w); + if (c.arg == 1 && arg1) { + arg1->begin = static_cast(start); + arg1->len = static_cast(pos - start); + } + at = c.end; + continue; + } + // A literal run up to the next '%'. + size_t next = at + 1; + while (next < flen && fmt[next] != '%') ++next; + put(fmt + at, next - at); + at = next; + } + if (cut) pos = CutUtf8(buf, pos); + buf[pos] = '\0'; + if (arg1 && arg1->begin >= 0 && static_cast(arg1->begin + arg1->len) > pos) { + arg1->len = static_cast(pos) - arg1->begin; + if (arg1->len <= 0) *arg1 = Span{}; + } + return static_cast(pos); +} + +} // namespace + +int Fmt(char* buf, size_t size, const char* fmt, ...) { + va_list ap; + va_start(ap, fmt); + const int n = Format(buf, size, nullptr, fmt, ap); + va_end(ap); + return n; +} + +int FmtSpan(char* buf, size_t size, Span* arg1, const char* fmt, ...) { + va_list ap; + va_start(ap, fmt); + const int n = Format(buf, size, arg1, fmt, ap); + va_end(ap); + return n; +} + +} // namespace l10n diff --git a/src/votv-coop/src/l10n/l10n.cpp b/src/votv-coop/src/l10n/l10n.cpp new file mode 100644 index 000000000..a67471657 --- /dev/null +++ b/src/votv-coop/src/l10n/l10n.cpp @@ -0,0 +1,221 @@ +// l10n/l10n.cpp -- the lookups, Label, and Init: the language chosen and its catalogue built once. + +#include "l10n/l10n.h" + +#include "catalog.h" +#include "locale_choice.h" +#include "l10n/po_reader.h" + +#include "ue_wrap/core/log.h" +#include "ue_wrap/core/paths.h" + +#define WIN32_LEAN_AND_MEAN +#include + +#include +#include +#include + +namespace l10n { +namespace { + +using detail::Catalog; +using detail::Entry; + +// Published once by Init and never freed: every pointer a lookup returned stays valid for the process. +std::atomic g_cat{nullptr}; +std::string g_locale; // written before g_cat's release store, read after its acquire load + +const Entry* Lookup(const char* ctx, const char* msgid) { + const Catalog* c = g_cat.load(std::memory_order_acquire); + if (!c || !msgid) return nullptr; + const Entry* e = c->Find(ctx, msgid); + return (e && !e->forms.empty()) ? e : nullptr; +} + +const char* Plural(const char* ctx, const char* singular, const char* plural, unsigned long long n) { + const Catalog* c = g_cat.load(std::memory_order_acquire); + if (c && singular) { + const Entry* e = c->Find(ctx, singular); + // A plural msgid answered by a singular entry is no answer: the English rule applies. + if (e && e->plural) return e->forms[c->SelectForm(*e, n)].c_str(); + } + return n == 1 ? singular : plural; +} + +// An RCDATA resource of our own module, by name; the bytes live in the mapped image for the process. +std::string_view Resource(const std::wstring& name) { + HMODULE self = nullptr; + ::GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT, + reinterpret_cast(&Resource), &self); + if (!self) return {}; + HRSRC res = ::FindResourceW(self, name.c_str(), reinterpret_cast(RT_RCDATA)); + if (!res) return {}; + HGLOBAL glob = ::LoadResource(self, res); + const DWORD size = ::SizeofResource(self, res); + const void* p = glob ? ::LockResource(glob) : nullptr; + if (!p || size == 0) return {}; + return std::string_view(static_cast(p), size); +} + +// A file of at most one byte past the reader's cap, so an oversize file is refused by the reader. +bool ReadFileBytes(const std::wstring& path, std::string& out) { + HANDLE h = ::CreateFileW(path.c_str(), GENERIC_READ, FILE_SHARE_READ, nullptr, OPEN_EXISTING, + FILE_ATTRIBUTE_NORMAL, nullptr); + if (h == INVALID_HANDLE_VALUE) return false; + out.resize(po::kMaxFileBytes + 1); + DWORD got = 0; + const BOOL ok = ::ReadFile(h, out.data(), static_cast(out.size()), &got, nullptr); + ::CloseHandle(h); + if (!ok) return false; + out.resize(got); + return true; +} + +std::wstring Widen(const std::string& s) { return std::wstring(s.begin(), s.end()); } // ASCII tags + +std::string ResourceName(const std::string& level) { + std::string n = "L10N_"; + for (char c : level) n.push_back((c >= 'a' && c <= 'z') ? static_cast(c - 'a' + 'A') : c); + return n; +} + +// One source into the catalogue, its outcome logged. Returns the rule its plural entries use. +int Load(Catalog& cat, const std::string& name, const std::string& level, std::string_view bytes, + int fallbackRule, size_t& refusedTotal, std::string& summary) { + const po::File f = po::Read(bytes); + if (!f.ok) { + UE_LOGW("l10n: %s refused whole (line %d: %s) -- nothing of it is used", name.c_str(), f.refusal.line, + f.refusal.what.c_str()); + summary += "; " + name + " refused"; + return fallbackRule; + } + if (f.charsetAssumed) UE_LOGI("l10n: %s names no charset -- read as UTF-8", name.c_str()); + if (!f.language.empty() && detail::NormaliseTag(f.language) != level) + UE_LOGW("l10n: %s says Language '%s' but is loaded as %s", name.c_str(), f.language.c_str(), level.c_str()); + detail::SourceReport rep; + rep.name = name; + const int rule = cat.Insert(f, fallbackRule, rep); + if (!rep.pluralNote.empty() && rule >= 0 && rule == fallbackRule) + UE_LOGI("l10n: %s has no usable Plural-Forms (%s) -- its plural entries take the pack's rule", + name.c_str(), rep.pluralNote.c_str()); + for (const po::Diag& d : rep.refused) + UE_LOGW("l10n: %s line %d refused: %s", name.c_str(), d.line, d.what.c_str()); + refusedTotal += rep.refused.size(); + summary += "; " + name + " " + std::to_string(rep.inserted) + " entries"; + if (!rep.translator.empty()) summary += " (translator " + rep.translator + ")"; + return rule; +} + +} // namespace + +const char* T(const char* msgid) { + const Entry* e = Lookup(nullptr, msgid); + return e ? e->forms[0].c_str() : msgid; +} + +const char* Tc(const char* ctx, const char* msgid) { + const Entry* e = Lookup(ctx, msgid); + return e ? e->forms[0].c_str() : msgid; +} + +const char* Tn(const char* singular, const char* plural, unsigned long long n) { + return Plural(nullptr, singular, plural, n); +} + +const char* Tcn(const char* ctx, const char* singular, const char* plural, unsigned long long n) { + return Plural(ctx, singular, plural, n); +} + +Label::Label(const char* text, const char* id) { + if (!text) text = ""; + if (!id) id = ""; + const size_t idLen = std::strlen(id); + if (idLen > 64) { + // An id is a literal of ours; one this long is a defect at its call site, said once. + static std::atomic s_said{false}; + if (!s_said.exchange(true)) UE_LOGE("l10n: a widget id of %zu bytes ('%.32s') -- keep ids short", idLen, id); + } + // The id is kept whole and the text is cut to fit, at a UTF-8 boundary: a cut id would collide. + const size_t tail = 3 + idLen; + size_t room = tail < sizeof(buf_) - 1 ? sizeof(buf_) - 1 - tail : 0; + size_t n = std::strlen(text); + if (n > room) { + n = room; + while (n > 0 && (static_cast(text[n]) & 0xC0) == 0x80) --n; // never split a sequence + } + std::memcpy(buf_, text, n); + size_t pos = n; + const size_t idKept = idLen < sizeof(buf_) - 1 - pos - 3 ? idLen : sizeof(buf_) - 1 - pos - 3; + std::memcpy(buf_ + pos, "###", 3); + pos += 3; + std::memcpy(buf_ + pos, id, idKept); + pos += idKept; + buf_[pos] = '\0'; +} + +const char* ActiveLocale() { + return g_cat.load(std::memory_order_acquire) ? g_locale.c_str() : ""; +} + +bool WasFound(const char* ctx, const char* msgid) { + const Catalog* c = g_cat.load(std::memory_order_acquire); + const Entry* e = (c && msgid) ? c->Peek(ctx, msgid) : nullptr; + return e && e->hit.load(std::memory_order_relaxed); +} + +size_t FoundCount() { + const Catalog* c = g_cat.load(std::memory_order_acquire); + return c ? c->FoundCount() : 0; +} + +void Init(const char* language) { + static std::atomic s_done{false}; + if (s_done.exchange(true)) return; + const std::string requested = (language && *language) ? language : "auto"; + std::string why, tag; + if (requested == "auto") { + const std::string win = detail::WindowsDisplayLanguage(); + tag = detail::NormaliseTag(win); + why = "auto: Windows display language " + (win.empty() ? std::string("unknown") : win); + } else { + tag = detail::NormaliseTag(requested); + why = "ui.language=" + requested; + } + if (tag.empty()) { + UE_LOGI("l10n: language English (%s) -- not a language tag, no catalogue", why.c_str()); + return; + } + // The levels, in rising priority: the language alone, then with its region. + const size_t us = tag.find('_'); + std::string levels[2]; + int nLevels = 0; + levels[nLevels++] = tag.substr(0, us); + if (us != std::string::npos) levels[nLevels++] = tag; + + auto* cat = new Catalog(); // immortal: see g_cat + size_t refused = 0; + std::string summary; + const std::wstring dir = ue_wrap::paths::ExeDir() + L"\\multivoid_locale\\"; + for (int i = 0; i < nLevels; ++i) { + const std::string& level = levels[i]; + int packRule = -1; + const std::string_view pack = Resource(Widen(ResourceName(level))); + if (!pack.empty()) + packRule = Load(*cat, "embedded pack " + level, level, pack, -1, refused, summary); + std::string bytes; + if (ReadFileBytes(dir + Widen(level) + L".po", bytes)) + Load(*cat, "override " + level + ".po", level, bytes, packRule, refused, summary); + } + if (cat->Size() == 0) { + UE_LOGI("l10n: language English (%s) -- no catalogue for %s%s", why.c_str(), tag.c_str(), + summary.empty() ? ", none embedded or in multivoid_locale" : summary.c_str()); + delete cat; + return; + } + g_locale = tag; + g_cat.store(cat, std::memory_order_release); + UE_LOGI("l10n: language %s (%s)%s, %zu refused", tag.c_str(), why.c_str(), summary.c_str(), refused); +} + +} // namespace l10n diff --git a/src/votv-coop/src/l10n/l10n_selftest.cpp b/src/votv-coop/src/l10n/l10n_selftest.cpp new file mode 100644 index 000000000..14de32342 --- /dev/null +++ b/src/votv-coop/src/l10n/l10n_selftest.cpp @@ -0,0 +1,258 @@ +// l10n/l10n_selftest.cpp -- l10n::RunSelftest: the reader, the plural evaluator, the conversion grammar, +// the formatter, Label, the language tag and the catalogue's merge, all on in-memory input. +// +// Pure: no engine, no file, no catalogue published. The plural forms it expects come from +// plural_vectors.inc, which an independent evaluator wrote, so a defect of ours is not mirrored in the +// expectation. Every pack embedded in this build is read too, and must load with nothing refused. + +#include "l10n/l10n.h" + +#include "catalog.h" +#include "locale_choice.h" +#include "l10n/plural_expr.h" +#include "l10n/po_reader.h" +#include "l10n/printf_check.h" + +#include "ue_wrap/core/log.h" + +#define WIN32_LEAN_AND_MEAN +#include + +#include +#include +#include + +namespace l10n { +namespace { + +#include "plural_vectors.inc" + +struct Checks { + int pass = 0, total = 0; + void Ok(bool cond, const char* what) { + ++total; + if (cond) ++pass; + else UE_LOGE("l10n selftest: FAIL -- %s", what); + } +}; + +void Plurals(Checks& c, bool injectRed) { + constexpr size_t nRules = sizeof(kRules) / sizeof(kRules[0]); + plural::Rule rules[nRules]; + bool compiled = true; + for (size_t i = 0; i < nRules; ++i) compiled = rules[i].Compile(kRules[i], nullptr) && compiled; + c.Ok(compiled, "every shipped plural rule compiles"); + bool all = true; + for (size_t k = 0; k < sizeof(kVectors) / sizeof(kVectors[0]); ++k) { + const Vector& v = kVectors[k]; + unsigned want = v.form; + // The red arm: one expected form made wrong, so the verdict hook is seen failing once. + if (injectRed && k == 0) want = want + 1; + if (rules[v.rule].Select(v.n) != want) { + all = false; + UE_LOGE("l10n selftest: FAIL -- rule %u at n=%llu took form %u, not %u", v.rule, v.n, + rules[v.rule].Select(v.n), want); + } + } + c.Ok(all, "every plural vector selects the form the independent evaluator gave"); + bool refused = true; + for (const char* r : kRefusedRules) { + plural::Rule x; + if (x.Compile(r, nullptr)) { + refused = false; + UE_LOGE("l10n selftest: FAIL -- a header that must be refused compiled: '%.60s'", r); + } + } + c.Ok(refused, "every malformed or hostile plural header is refused"); + plural::Rule z; + c.Ok(z.Compile("nplurals=2; plural=n/0;", nullptr) && z.Select(5) == 0, "a division by zero is form 0"); + c.Ok(z.Compile("nplurals=2; plural=n;", nullptr) && z.Select(7) == 0, "a form at or above nplurals is form 0"); +} + +bool Accepts(const char* f) { + printf_check::Parsed p; + return printf_check::Parse(f, &p, nullptr); +} + +bool Match(const char* msgid, const char* tr) { + printf_check::Parsed a, b; + return printf_check::Parse(msgid, &a, nullptr) && printf_check::Parse(tr, &b, nullptr) && + printf_check::Matches(a, b, nullptr); +} + +void Conversions(Checks& c) { + c.Ok(Accepts("%d of %s, %.1f%% at %zu, %llu, %08x, %-5s"), "the accepted conversions parse"); + c.Ok(!Accepts("%n"), "%n is refused"); + c.Ok(!Accepts("%*d") && !Accepts("%.*f"), "a '*' width or precision is refused"); + c.Ok(!Accepts("%s and %1$s"), "mixed numbering is refused"); + c.Ok(!Accepts("%2$s only"), "a numbering gap is refused"); + c.Ok(!Accepts("%1$s %1$s"), "an argument read twice is refused"); + c.Ok(!Accepts("%ls") && !Accepts("%c") && !Accepts("%hd") && !Accepts("%lf"), "%ls, %c, %hd and %lf are refused"); + c.Ok(!Accepts("%.3s") && !Accepts("%05s") && !Accepts("%+u") && !Accepts("%#d"), "flags C leaves undefined are refused"); + c.Ok(!Accepts("%123d") && !Accepts("100% sure"), "a three-digit width and a bare '%' are refused"); + c.Ok(Match("%s took %d", "%2$d by %1$s"), "a reordered translation matches"); + c.Ok(Match("%d items", "%i items") && Match("%5.2f", "%.1f"), "d and i are one type; width and precision may differ"); + c.Ok(!Match("%s took %d", "%s took %s") && !Match("%d", "%ld"), "a type or length change is refused"); + c.Ok(!Match("%s and %s", "%s"), "a missing conversion is refused"); +} + +void Formatter(Checks& c) { + char buf[64]; + c.Ok(Fmt(buf, sizeof(buf), "%2$s, %1$s", "a", "b") == 4 && std::strcmp(buf, "b, a") == 0, "Fmt reorders"); + c.Ok(Fmt(buf, sizeof(buf), "%d%% of %llu", 50, 7ull) > 0 && std::strcmp(buf, "50% of 7") == 0, "Fmt formats"); + Span sp; + c.Ok(FmtSpan(buf, sizeof(buf), &sp, "%2$s greets %1$s.", "Nick", "Bob") > 0 && sp.begin == 11 && sp.len == 4, + "FmtSpan reports where argument 1 landed"); + // A cut never splits a sequence: U+4E2D is three bytes, and four fit before the cut. + char small[6]; + const int n = Fmt(small, sizeof(small), "ab%s", "\xE4\xB8\xAD\xE4\xB8\xAD"); + c.Ok(n == 5 && std::strcmp(small, "ab\xE4\xB8\xAD") == 0, "a cut keeps whole UTF-8 sequences"); + c.Ok(FmtSpan(small, sizeof(small), &sp, "abcd%s", "Nick") == 5 && sp.begin == 4 && sp.len == 1, + "a span the cut reaches is clipped"); + const Label l("Apply", "apply"); + c.Ok(std::strcmp(l, "Apply###apply") == 0, "Label composes text and id"); + std::string longText(300, 'x'); + const Label cut(longText.c_str(), "keep_me"); + const size_t len = std::strlen(cut); + c.Ok(len == 255 && std::strcmp(static_cast(cut) + len - 10, "###keep_me") == 0, + "a long label is cut in its text and keeps its id"); +} + +void Tags(Checks& c) { + using detail::NormaliseTag; + c.Ok(NormaliseTag("zh-CN") == "zh_CN" && NormaliseTag("zh_CN.UTF-8") == "zh_CN", "zh-CN is zh_CN"); + c.Ok(NormaliseTag("zh-Hans-CN") == "zh_CN" && NormaliseTag("zh-Hans") == "zh_CN" && + NormaliseTag("zh-Hant-TW") == "zh_TW" && NormaliseTag("zh-Hant") == "zh_TW", "Chinese scripts pick a region"); + c.Ok(NormaliseTag("pt-BR") == "pt_BR" && NormaliseTag("en-US") == "en_US" && NormaliseTag("ru") == "ru" && + NormaliseTag("sr-Latn") == "sr" && NormaliseTag("es-419") == "es_419", "regions and scripts elsewhere"); + c.Ok(NormaliseTag("").empty() && NormaliseTag("x").empty() && NormaliseTag("12-AB").empty() && + NormaliseTag("auto!").empty(), "garbage is no tag"); +} + +void Reader(Checks& c) { + const char* kGood = + "\xEF\xBB\xBF# a translator comment\n" + "msgid \"\"\nmsgstr \"\"\n\"Language: ru\\n\"\n\"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : " + "n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\\n\"\n\"Content-Type: text/plain; charset=UTF-8\\n\"\n\n" + "#: ui/a.cpp\nmsgid \"Apply\"\nmsgstr \"\\u041f\"\n\n" // an escape outside the set: refused + "msgctxt \"f1\"\nmsgid \"Rules\"\nmsgstr \"Rul\" \"es\\t\\a\\101\\x42\"\n\n" // adjacent strings, escapes + "#, fuzzy\nmsgid \"Fuzzy\"\nmsgstr \"F\"\n\n" // skipped + "msgid \"Empty\"\nmsgstr \"\"\n\n" // untranslated + "msgid \"%llu peer\"\nmsgid_plural \"%llu peers\"\nmsgstr[0] \"%llu a\"\nmsgstr[1] \"%llu b\"\n" + "msgstr[2] \"%llu c\"\n\n" + "msgid \"%llu x\"\nmsgid_plural \"%llu xs\"\nmsgstr[0] \"%llu\"\nmsgstr[1] \"\"\nmsgstr[2] \"%llu\"\n\n" // half + "msgid \"%s took %d\"\nmsgstr \"%2$d by %1$s\"\n\n" + "msgid \"%s took\"\nmsgstr \"%d\"\n\n" // a type change: refused + "msgid \"Plain\"\nmsgstr \"100%\"\n\n" // a '%' in a plain line: refused + "msgid \"Nul\"\nmsgstr \"a\\0b\"\n\n" // a NUL escape: refused + "#~ msgid \"Old\"\n#~ msgstr \"Alt\"\n"; + const po::File f = po::Read(kGood); + if (!f.ok) UE_LOGE("l10n selftest: the well-formed file was refused at line %d: %s", f.refusal.line, f.refusal.what.c_str()); + c.Ok(f.ok, "a well-formed file loads"); + c.Ok(f.language == "ru" && !f.pluralForms.empty() && !f.charsetAssumed, "the header is read"); + auto find = [&](const char* id, bool ctx) -> const po::Entry* { + for (const po::Entry& e : f.entries) + if (e.id == id && e.hasCtx == ctx) return &e; + return nullptr; + }; + const po::Entry* rules = find("Rules", true); + c.Ok(rules && rules->ctx == "f1" && rules->str[0] == "Rules\t\aAB", "adjacent strings join and escapes decode"); + c.Ok(!find("Apply", false) && !find("Fuzzy", false) && !find("Empty", false) && !find("Old", false), + "an unknown escape, a fuzzy, an empty and an obsolete entry are not translations"); + const po::Entry* pl = find("%llu peer", false); + c.Ok(pl && pl->plural && pl->str.size() == 3, "a plural entry keeps its forms"); + c.Ok(!find("%llu x", false), "a half-filled plural is refused"); + c.Ok(find("%s took %d", false) && !find("%s took", false), "conversions are checked against the msgid"); + c.Ok(!find("Plain", false) && !find("Nul", false), "a '%' in a plain line and a NUL escape are refused"); + c.Ok(f.refused.size() == 5, "each refused entry is reported once"); + + c.Ok(!po::Read("msgid \"a\nmsgstr \"b\"\n").ok, "a string that never closes refuses the file"); + c.Ok(!po::Read("msgid \"\"\nmsgstr \"Content-Type: text/plain; charset=ISO-8859-1\\n\"\n").ok, + "a charset other than UTF-8 refuses the file"); + c.Ok(!po::Read("msgid \"a\"\nmsgstr \"\xC3\x28\"\n").ok, "bytes that are not UTF-8 refuse the file"); + c.Ok(!po::Read("garbage\n").ok && !po::Read("msgid \"a\" x\nmsgstr \"b\"\n").ok, + "a line that is no keyword, and text after a string, refuse the file"); + const po::File tmpl = po::Read("msgid \"\"\nmsgstr \"Content-Type: text/plain; charset=CHARSET\\n\"\n"); + c.Ok(tmpl.ok && tmpl.charsetAssumed, "a template's CHARSET is read as UTF-8"); + std::string big(po::kMaxFileBytes + 1, '#'); + c.Ok(!po::Read(big).ok, "a file over 1 MiB is refused"); +} + +void Merge(Checks& c) { + // Four sources in rising priority, as Init inserts them: ll pack, ll override, ll_CC pack, ll_CC override. + const char* hdr = "msgid \"\"\nmsgstr \"Plural-Forms: nplurals=2; plural=(n != 1);\\n\"\n\n"; + const std::string llPack = std::string(hdr) + "msgid \"A\"\nmsgstr \"a-ll-pack\"\n\nmsgid \"B\"\nmsgstr \"b-ll-pack\"\n\n" + "msgid \"%llu n\"\nmsgid_plural \"%llu ns\"\nmsgstr[0] \"%llu one\"\nmsgstr[1] \"%llu many\"\n"; + const std::string llOver = "msgid \"B\"\nmsgstr \"b-ll-over\"\n\nmsgid \"C\"\nmsgstr \"c-ll-over\"\n\n" + "msgid \"%llu n\"\nmsgid_plural \"%llu ns\"\nmsgstr[0] \"%llu uno\"\nmsgstr[1] \"%llu muchos\"\n"; + const std::string ccPack = std::string(hdr) + "msgid \"C\"\nmsgstr \"c-cc-pack\"\n\nmsgid \"D\"\nmsgstr \"d-cc-pack\"\n"; + const std::string ccOver = "msgid \"D\"\nmsgstr \"\"\n\nmsgid \"E\"\nmsgstr \"%s\"\n"; // empty, and refused + detail::Catalog cat; + detail::SourceReport r1, r2, r3, r4; + const int llRule = cat.Insert(po::Read(llPack), -1, r1); + cat.Insert(po::Read(llOver), llRule, r2); + const int ccRule = cat.Insert(po::Read(ccPack), -1, r3); + cat.Insert(po::Read(ccOver), ccRule, r4); + auto text = [&](const char* id) -> std::string { + const detail::Entry* e = cat.Peek(nullptr, id); + return e ? e->forms[0] : std::string(); + }; + c.Ok(text("A") == "a-ll-pack" && text("B") == "b-ll-over" && text("C") == "c-cc-pack" && text("D") == "d-cc-pack", + "ll_CC override > ll_CC pack > ll override > ll pack"); + c.Ok(cat.Peek(nullptr, "E") == nullptr && r4.refused.size() == 1, "a refused override entry inserts nothing"); + const detail::Entry* pl = cat.Peek(nullptr, "%llu n"); + c.Ok(pl && pl->plural && pl->forms[cat.SelectForm(*pl, 1)] == "%llu uno" && + pl->forms[cat.SelectForm(*pl, 5)] == "%llu muchos", "an override's plural entry takes the pack's rule"); + c.Ok(!cat.Peek(nullptr, "A")->hit.load() && cat.Find(nullptr, "A") && cat.Peek(nullptr, "A")->hit.load() && + cat.FoundCount() == 1, "a found lookup marks its entry; a peek does not"); +} + +// Every pack this build embeds: named L10N_, and each must load with nothing refused. +void Embedded(Checks& c) { + HMODULE self = nullptr; + ::GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT, + reinterpret_cast(&Embedded), &self); + std::vector names; + ::EnumResourceNamesW(self, reinterpret_cast(RT_RCDATA), + [](HMODULE, LPCWSTR, LPWSTR name, LONG_PTR ctx) -> BOOL { + if (!IS_INTRESOURCE(name) && std::wcsncmp(name, L"L10N_", 5) == 0) + reinterpret_cast*>(ctx)->push_back(name); + return TRUE; + }, + reinterpret_cast(&names)); + for (const std::wstring& name : names) { + HRSRC res = ::FindResourceW(self, name.c_str(), reinterpret_cast(RT_RCDATA)); + HGLOBAL glob = res ? ::LoadResource(self, res) : nullptr; + const char* p = glob ? static_cast(::LockResource(glob)) : nullptr; + const po::File f = po::Read(p ? std::string_view(p, ::SizeofResource(self, res)) : std::string_view()); + detail::Catalog cat; + detail::SourceReport rep; + if (f.ok) cat.Insert(f, -1, rep); + const bool clean = f.ok && rep.refused.empty(); + if (!clean) + UE_LOGE("l10n selftest: FAIL -- the embedded pack %ls %s", name.c_str(), + f.ok ? "has refused entries" : f.refusal.what.c_str()); + c.Ok(clean, "an embedded pack loads with nothing refused"); + } +} + +} // namespace + +bool RunSelftest(bool injectRed) { + Checks c; + Plurals(c, injectRed); + Conversions(c); + Formatter(c); + Tags(c); + Reader(c); + Merge(c); + Embedded(c); + if (c.pass == c.total) { + UE_LOGI("l10n selftest: ALL PASS (%d checks)", c.total); + return true; + } + UE_LOGE("l10n selftest: %d/%d checks passed", c.pass, c.total); + return false; +} + +} // namespace l10n diff --git a/src/votv-coop/src/l10n/locale_choice.cpp b/src/votv-coop/src/l10n/locale_choice.cpp new file mode 100644 index 000000000..47c312b70 --- /dev/null +++ b/src/votv-coop/src/l10n/locale_choice.cpp @@ -0,0 +1,73 @@ +// l10n/locale_choice.cpp -- see l10n/locale_choice.h. + +#include "locale_choice.h" + +#define WIN32_LEAN_AND_MEAN +#include + +#include + +namespace l10n::detail { +namespace { + +bool IsAlpha(char c) { return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z'); } +bool IsDigit(char c) { return c >= '0' && c <= '9'; } +char Lower(char c) { return (c >= 'A' && c <= 'Z') ? static_cast(c - 'A' + 'a') : c; } +char Upper(char c) { return (c >= 'a' && c <= 'z') ? static_cast(c - 'a' + 'A') : c; } + +} // namespace + +std::string NormaliseTag(std::string_view tag) { + // A gettext locale may carry an encoding or a modifier: `zh_CN.UTF-8`, `sr_RS@latin`. + const size_t cut = tag.find_first_of(".@"); + if (cut != std::string_view::npos) tag = tag.substr(0, cut); + std::vector parts; + size_t at = 0; + while (at <= tag.size()) { + size_t end = tag.find_first_of("-_", at); + if (end == std::string_view::npos) end = tag.size(); + parts.push_back(tag.substr(at, end - at)); + at = end + 1; + } + if (parts.empty() || parts[0].size() < 2 || parts[0].size() > 3) return {}; + for (char c : parts[0]) + if (!IsAlpha(c)) return {}; + std::string lang; + for (char c : parts[0]) lang.push_back(Lower(c)); + std::string script, region; + for (size_t i = 1; i < parts.size(); ++i) { + const std::string_view p = parts[i]; + bool alpha = !p.empty(), digits = !p.empty(); + for (char c : p) { + alpha = alpha && IsAlpha(c); + digits = digits && IsDigit(c); + } + if (script.empty() && region.empty() && p.size() == 4 && alpha) { + for (char c : p) script.push_back(Lower(c)); + } else if (region.empty() && ((p.size() == 2 && alpha) || (p.size() == 3 && digits))) { + for (char c : p) region.push_back(Upper(c)); + } + // Variants and extensions say nothing a catalogue is chosen by. + } + if (region.empty() && lang == "zh") { + if (script == "hans") region = "CN"; + else if (script == "hant") region = "TW"; + } + return region.empty() ? lang : lang + "_" + region; +} + +std::string WindowsDisplayLanguage() { + ULONG count = 0, chars = 0; + if (!::GetUserPreferredUILanguages(MUI_LANGUAGE_NAME, &count, nullptr, &chars) || chars == 0) return {}; + std::vector buf(chars); + if (!::GetUserPreferredUILanguages(MUI_LANGUAGE_NAME, &count, buf.data(), &chars) || count == 0) return {}; + // A double-NUL-terminated list; the first entry is the language in use. + std::string out; + for (const wchar_t* p = buf.data(); *p; ++p) { + if (*p > 0x7F) return {}; // a language name is ASCII + out.push_back(static_cast(*p)); + } + return out; +} + +} // namespace l10n::detail diff --git a/src/votv-coop/src/l10n/locale_choice.h b/src/votv-coop/src/l10n/locale_choice.h new file mode 100644 index 000000000..d5a5a2a5d --- /dev/null +++ b/src/votv-coop/src/l10n/locale_choice.h @@ -0,0 +1,23 @@ +// l10n/locale_choice.h -- which language the interface is in (private to src/l10n/). +// +// `auto` is the Windows display language -- what the player reads Windows in, not the regional format +// a player with an English Windows and Russian number formats has -- through +// GetUserPreferredUILanguages, the SDK's current call (GetUserDefaultUILanguage and LCIDToLocaleName are +// marked deprecated in winnls.h). MTA stores a `locale` setting its installer chose, default en_US +// (Client/core/CLocalization.cpp); the mod has no installer, so the OS is the only signal at first start. +#pragma once + +#include +#include + +namespace l10n::detail { + +// A BCP-47 or gettext tag in gettext form: `zh-CN` and `zh_CN.UTF-8` are `zh_CN`; a script subtag with a +// region is dropped (`zh-Hans-CN` is `zh_CN`), one without picks its region for Chinese (`zh-Hans` is +// `zh_CN`, `zh-Hant` is `zh_TW`) and is dropped otherwise; "" when the text is not a language tag. +std::string NormaliseTag(std::string_view tag); + +// The first of the user's preferred display languages, as Windows names it (`zh-CN`), or "". +std::string WindowsDisplayLanguage(); + +} // namespace l10n::detail diff --git a/src/votv-coop/src/l10n/plural_expr.cpp b/src/votv-coop/src/l10n/plural_expr.cpp new file mode 100644 index 000000000..c2dd777ba --- /dev/null +++ b/src/votv-coop/src/l10n/plural_expr.cpp @@ -0,0 +1,259 @@ +// l10n/plural_expr.cpp -- see l10n/plural_expr.h. + +#include "l10n/plural_expr.h" + +#include + +namespace l10n::plural { + +enum Op : uint8_t { + kNum, kVarN, kNot, kMul, kDiv, kMod, kAdd, kSub, + kLt, kGt, kLe, kGe, kEq, kNe, kAnd, kOr, kCond, +}; + +// Recursive descent, one function per precedence level of GNU's grammar (plural.y): `?:` binds +// loosest and associates to the right, then `||`, `&&`, `== !=`, `< > <= >=`, `+ -`, `* / %`, and +// the unary `!`. Nesting counts the levels that can recurse without consuming an operator chain -- +// a parenthesis, a `!`, a `?:` branch -- so a long flat rule (ru, pl) is never refused for its +// length of precedence levels, and a hostile `((((...` or `!!!!...` is refused at kMaxNesting. +class Parser { +public: + Parser(std::string_view s, std::vector& out) : s_(s), out_(out) {} + + bool Parse(int32_t* root, std::string* why) { + const int32_t r = Cond(); + Skip(); + if (!err_ && pos_ != s_.size()) Fail("input left over after the expression"); + if (err_) { if (why) *why = err_; return false; } + *root = r; + return true; + } + +private: + void Skip() { while (pos_ < s_.size() && std::isspace(static_cast(s_[pos_]))) ++pos_; } + bool Eat(char c) { + Skip(); + if (pos_ < s_.size() && s_[pos_] == c) { ++pos_; return true; } + return false; + } + bool Eat2(char c0, char c1) { + Skip(); + if (pos_ + 1 < s_.size() && s_[pos_] == c0 && s_[pos_ + 1] == c1) { pos_ += 2; return true; } + return false; + } + char Peek() { Skip(); return pos_ < s_.size() ? s_[pos_] : '\0'; } + char Peek2() { Skip(); return pos_ + 1 < s_.size() ? s_[pos_ + 1] : '\0'; } + int32_t Fail(const char* why) { if (!err_) err_ = why; return -1; } + int32_t Emit(uint8_t op, int32_t a = -1, int32_t b = -1, int32_t c = -1, uint64_t v = 0) { + if (err_) return -1; + Rule::Node n; + n.op = op; n.a = a; n.b = b; n.c = c; n.value = v; + out_.push_back(n); + return static_cast(out_.size() - 1); + } + bool Enter() { + if (++depth_ > kMaxNesting) { Fail("the expression nests deeper than 32"); return false; } + return true; + } + + int32_t Cond() { + const int32_t c = Or(); + if (err_ || !Eat('?')) return c; + if (!Enter()) return -1; + const int32_t t = Cond(); + if (!Eat(':')) return Fail("a '?' without its ':'"); + const int32_t f = Cond(); + --depth_; + return Emit(kCond, c, t, f); + } + int32_t Or() { + int32_t l = And(); + while (!err_ && Eat2('|', '|')) l = Emit(kOr, l, And()); + return l; + } + int32_t And() { + int32_t l = Eq(); + while (!err_ && Eat2('&', '&')) l = Emit(kAnd, l, Eq()); + return l; + } + int32_t Eq() { + int32_t l = Rel(); + for (;;) { + if (err_) return -1; + if (Eat2('=', '=')) l = Emit(kEq, l, Rel()); + else if (Eat2('!', '=')) l = Emit(kNe, l, Rel()); + else return l; + } + } + int32_t Rel() { + int32_t l = Add(); + for (;;) { + if (err_) return -1; + if (Eat2('<', '=')) l = Emit(kLe, l, Add()); + else if (Eat2('>', '=')) l = Emit(kGe, l, Add()); + else if (Eat('<')) l = Emit(kLt, l, Add()); + else if (Eat('>')) l = Emit(kGt, l, Add()); + else return l; + } + } + int32_t Add() { + int32_t l = Mul(); + for (;;) { + if (err_) return -1; + if (Eat('+')) l = Emit(kAdd, l, Mul()); + else if (Eat('-')) l = Emit(kSub, l, Mul()); + else return l; + } + } + int32_t Mul() { + int32_t l = Unary(); + for (;;) { + if (err_) return -1; + if (Eat('*')) l = Emit(kMul, l, Unary()); + else if (Eat('/')) l = Emit(kDiv, l, Unary()); + else if (Eat('%')) l = Emit(kMod, l, Unary()); + else return l; + } + } + int32_t Unary() { + // `!=` is the equality operator, not a `!` before `=`. + if (Peek() == '!' && Peek2() != '=') { + ++pos_; + if (!Enter()) return -1; + const int32_t a = Unary(); + --depth_; + return Emit(kNot, a); + } + return Primary(); + } + int32_t Primary() { + const char c = Peek(); + if (c == 'n') { ++pos_; return Emit(kVarN); } + if (c >= '0' && c <= '9') { + uint64_t v = 0; + int digits = 0; + while (pos_ < s_.size() && s_[pos_] >= '0' && s_[pos_] <= '9') { + if (++digits > 19) return Fail("a number longer than 19 digits"); + v = v * 10 + static_cast(s_[pos_++] - '0'); + } + return Emit(kNum, -1, -1, -1, v); + } + if (c == '(') { + ++pos_; + if (!Enter()) return -1; + const int32_t e = Cond(); + if (!Eat(')')) return Fail("a '(' without its ')'"); + --depth_; + return e; + } + return Fail(c ? "a token outside the plural grammar" : "the expression ends early"); + } + + std::string_view s_; + std::vector& out_; + size_t pos_ = 0; + unsigned depth_ = 0; + const char* err_ = nullptr; +}; + +namespace { + +// The value after `key=` up to the next ';' (or the end), trimmed. +bool Field(std::string_view s, std::string_view key, std::string_view* out) { + for (size_t at = 0; (at = s.find(key, at)) != std::string_view::npos; at += key.size()) { + if (at > 0 && (std::isalnum(static_cast(s[at - 1])) || s[at - 1] == '_')) continue; + size_t p = at + key.size(); + while (p < s.size() && std::isspace(static_cast(s[p]))) ++p; + if (p >= s.size() || s[p] != '=') continue; + ++p; + size_t end = s.find(';', p); + if (end == std::string_view::npos) end = s.size(); + std::string_view v = s.substr(p, end - p); + while (!v.empty() && std::isspace(static_cast(v.front()))) v.remove_prefix(1); + while (!v.empty() && std::isspace(static_cast(v.back()))) v.remove_suffix(1); + *out = v; + return true; + } + return false; +} + +} // namespace + +bool Rule::Compile(std::string_view headerValue, std::string* why) { + nodes_.clear(); + root_ = -1; + nplurals_ = 0; + std::string_view count, expr; + if (!Field(headerValue, "nplurals", &count) || !Field(headerValue, "plural", &expr)) { + if (why) *why = "no nplurals= and plural= pair"; + return false; + } + unsigned n = 0; + if (count.empty() || count.size() > 2) { + if (why) *why = "nplurals is not a number from 1 to 8"; + return false; + } + for (char c : count) { + if (c < '0' || c > '9') { if (why) *why = "nplurals is not a number from 1 to 8"; return false; } + n = n * 10 + static_cast(c - '0'); + } + if (n < 1 || n > kMaxForms) { + if (why) *why = "nplurals is not a number from 1 to 8"; + return false; + } + if (expr.size() > kMaxExprChars) { + if (why) *why = "the plural expression is longer than 256 characters"; + return false; + } + std::vector nodes; + int32_t root = -1; + Parser p(expr, nodes); + if (!p.Parse(&root, why)) return false; + nodes_ = std::move(nodes); + root_ = root; + nplurals_ = n; + return true; +} + +unsigned Rule::Select(uint64_t n) const { + if (root_ < 0) return 0; + // The rule is immutable after Compile and Select may run on several threads at once, so `n` + // cannot be written into the node array: the walk carries it instead. + struct Walk { + const std::vector& nodes; + uint64_t n; + bool divZero; + uint64_t Go(int32_t i) { + const Node& k = nodes[static_cast(i)]; + switch (k.op) { + case kNum: return k.value; + case kVarN: return n; + case kNot: return Go(k.a) == 0 ? 1 : 0; + case kAnd: return (Go(k.a) != 0 && Go(k.b) != 0) ? 1 : 0; + case kOr: return (Go(k.a) != 0 || Go(k.b) != 0) ? 1 : 0; + case kCond: return Go(k.a) != 0 ? Go(k.b) : Go(k.c); + default: break; + } + const uint64_t l = Go(k.a), r = Go(k.b); + switch (k.op) { + case kMul: return l * r; + case kDiv: if (r == 0) { divZero = true; return 0; } return l / r; + case kMod: if (r == 0) { divZero = true; return 0; } return l % r; + case kAdd: return l + r; + case kSub: return l - r; + case kLt: return l < r ? 1 : 0; + case kGt: return l > r ? 1 : 0; + case kLe: return l <= r ? 1 : 0; + case kGe: return l >= r ? 1 : 0; + case kEq: return l == r ? 1 : 0; + case kNe: return l != r ? 1 : 0; + default: return 0; + } + } + } w{nodes_, n, false}; + const uint64_t form = w.Go(root_); + if (w.divZero || form >= nplurals_) return 0; + return static_cast(form); +} + +} // namespace l10n::plural diff --git a/src/votv-coop/src/l10n/plural_vectors.inc b/src/votv-coop/src/l10n/plural_vectors.inc new file mode 100644 index 000000000..e3ccbba6d --- /dev/null +++ b/src/votv-coop/src/l10n/plural_vectors.inc @@ -0,0 +1,99 @@ +// l10n/plural_vectors.inc -- GENERATED by .github/ci/l10n_vectors.py, never hand-edited. The +// forms are Python's gettext.c2py's, an evaluator independent of src/l10n/plural_expr.cpp. + +constexpr const char* kRules[] = { + "nplurals=1; plural=0;", + "nplurals=2; plural=(n != 1);", + "nplurals=2; plural=(n > 1);", + "nplurals=2; plural=(n%10==1 && n%100!=11 ? 0 : 1);", + "nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);", + "nplurals=3; plural=(n==1 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);", + "nplurals=3; plural=(n==1) ? 0 : (n>=2 && n<=4) ? 1 : 2;", + "nplurals=3; plural=n==1 ? 0 : n==2 ? 1 : 2;", + "nplurals=3; plural=n%10==1 && n%100!=11 ? 0 : n != 0 ? 1 : 2;", + "nplurals=3; plural=(n==1 ? 0 : (n==0 || (n%100 > 0 && n%100 < 20)) ? 1 : 2);", + "nplurals=4; plural=(n==1) ? 0 : (n>=2 && n<=4) ? 1 : 3;", + "nplurals=4; plural=n%100==1 ? 0 : n%100==2 ? 1 : n%100==3 || n%100==4 ? 2 : 3;", + "nplurals=4; plural=(n%10==1 && (n%100>19 || n%100<11) ? 0 : (n%10>=2 && n%10<=9) && (n%100>19 || n%100<" + "11) ? 1 : n%1!=0 ? 2 : 3);", + "nplurals=4; plural=(n==1 ? 0 : (n%10>=2 && n%10<=4) && (n%100<12 || n%100>14) ? 1 : n!=1 && (n%10>=0 &&" + " n%10<=1) || (n%10>=5 && n%10<=9) || (n%100>=12 && n%100<=14) ? 2 : 3);", + "nplurals=4; plural=((n%10==1 && n%100!=11) ? 0 : ((n%10>=2 && n%10<=4 && (n%100<12 || n%100>14)) ? 1 : " + "((n%10==0 || (n%10>=5 && n%10<=9)) || (n%100>=11 && n%100<=14)) ? 2 : 3));", + "nplurals=6; plural=(n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 && n%100<=10 ? 3 : n%100>=11 && n%100<=99" + " ? 4 : 5);", +}; + +struct Vector { unsigned char rule; unsigned long long n; unsigned char form; }; +constexpr Vector kVectors[] = { + {0, 0ull, 0}, {0, 1ull, 0}, {0, 2ull, 0}, {0, 3ull, 0}, {0, 4ull, 0}, {0, 5ull, 0}, {0, 7ull, 0}, + {0, 11ull, 0}, {0, 12ull, 0}, {0, 14ull, 0}, {0, 21ull, 0}, {0, 22ull, 0}, {0, 25ull, 0}, {0, 100ull, 0}, + {0, 101ull, 0}, {0, 102ull, 0}, {0, 111ull, 0}, {0, 1000000ull, 0}, {0, 9223372036854775808ull, 0}, + {1, 0ull, 1}, {1, 1ull, 0}, {1, 2ull, 1}, {1, 3ull, 1}, {1, 4ull, 1}, {1, 5ull, 1}, {1, 7ull, 1}, + {1, 11ull, 1}, {1, 12ull, 1}, {1, 14ull, 1}, {1, 21ull, 1}, {1, 22ull, 1}, {1, 25ull, 1}, {1, 100ull, 1}, + {1, 101ull, 1}, {1, 102ull, 1}, {1, 111ull, 1}, {1, 1000000ull, 1}, {1, 9223372036854775808ull, 1}, + {2, 0ull, 0}, {2, 1ull, 0}, {2, 2ull, 1}, {2, 3ull, 1}, {2, 4ull, 1}, {2, 5ull, 1}, {2, 7ull, 1}, + {2, 11ull, 1}, {2, 12ull, 1}, {2, 14ull, 1}, {2, 21ull, 1}, {2, 22ull, 1}, {2, 25ull, 1}, {2, 100ull, 1}, + {2, 101ull, 1}, {2, 102ull, 1}, {2, 111ull, 1}, {2, 1000000ull, 1}, {2, 9223372036854775808ull, 1}, + {3, 0ull, 1}, {3, 1ull, 0}, {3, 2ull, 1}, {3, 3ull, 1}, {3, 4ull, 1}, {3, 5ull, 1}, {3, 7ull, 1}, + {3, 11ull, 1}, {3, 12ull, 1}, {3, 14ull, 1}, {3, 21ull, 0}, {3, 22ull, 1}, {3, 25ull, 1}, {3, 100ull, 1}, + {3, 101ull, 0}, {3, 102ull, 1}, {3, 111ull, 1}, {3, 1000000ull, 1}, {3, 9223372036854775808ull, 1}, + {4, 0ull, 2}, {4, 1ull, 0}, {4, 2ull, 1}, {4, 3ull, 1}, {4, 4ull, 1}, {4, 5ull, 2}, {4, 7ull, 2}, + {4, 11ull, 2}, {4, 12ull, 2}, {4, 14ull, 2}, {4, 21ull, 0}, {4, 22ull, 1}, {4, 25ull, 2}, {4, 100ull, 2}, + {4, 101ull, 0}, {4, 102ull, 1}, {4, 111ull, 2}, {4, 1000000ull, 2}, {4, 9223372036854775808ull, 2}, + {5, 0ull, 2}, {5, 1ull, 0}, {5, 2ull, 1}, {5, 3ull, 1}, {5, 4ull, 1}, {5, 5ull, 2}, {5, 7ull, 2}, + {5, 11ull, 2}, {5, 12ull, 2}, {5, 14ull, 2}, {5, 21ull, 2}, {5, 22ull, 1}, {5, 25ull, 2}, {5, 100ull, 2}, + {5, 101ull, 2}, {5, 102ull, 1}, {5, 111ull, 2}, {5, 1000000ull, 2}, {5, 9223372036854775808ull, 2}, + {6, 0ull, 2}, {6, 1ull, 0}, {6, 2ull, 1}, {6, 3ull, 1}, {6, 4ull, 1}, {6, 5ull, 2}, {6, 7ull, 2}, + {6, 11ull, 2}, {6, 12ull, 2}, {6, 14ull, 2}, {6, 21ull, 2}, {6, 22ull, 2}, {6, 25ull, 2}, {6, 100ull, 2}, + {6, 101ull, 2}, {6, 102ull, 2}, {6, 111ull, 2}, {6, 1000000ull, 2}, {6, 9223372036854775808ull, 2}, + {7, 0ull, 2}, {7, 1ull, 0}, {7, 2ull, 1}, {7, 3ull, 2}, {7, 4ull, 2}, {7, 5ull, 2}, {7, 7ull, 2}, + {7, 11ull, 2}, {7, 12ull, 2}, {7, 14ull, 2}, {7, 21ull, 2}, {7, 22ull, 2}, {7, 25ull, 2}, {7, 100ull, 2}, + {7, 101ull, 2}, {7, 102ull, 2}, {7, 111ull, 2}, {7, 1000000ull, 2}, {7, 9223372036854775808ull, 2}, + {8, 0ull, 2}, {8, 1ull, 0}, {8, 2ull, 1}, {8, 3ull, 1}, {8, 4ull, 1}, {8, 5ull, 1}, {8, 7ull, 1}, + {8, 11ull, 1}, {8, 12ull, 1}, {8, 14ull, 1}, {8, 21ull, 0}, {8, 22ull, 1}, {8, 25ull, 1}, {8, 100ull, 1}, + {8, 101ull, 0}, {8, 102ull, 1}, {8, 111ull, 1}, {8, 1000000ull, 1}, {8, 9223372036854775808ull, 1}, + {9, 0ull, 1}, {9, 1ull, 0}, {9, 2ull, 1}, {9, 3ull, 1}, {9, 4ull, 1}, {9, 5ull, 1}, {9, 7ull, 1}, + {9, 11ull, 1}, {9, 12ull, 1}, {9, 14ull, 1}, {9, 21ull, 2}, {9, 22ull, 2}, {9, 25ull, 2}, {9, 100ull, 2}, + {9, 101ull, 1}, {9, 102ull, 1}, {9, 111ull, 1}, {9, 1000000ull, 2}, {9, 9223372036854775808ull, 1}, + {10, 0ull, 3}, {10, 1ull, 0}, {10, 2ull, 1}, {10, 3ull, 1}, {10, 4ull, 1}, {10, 5ull, 3}, {10, 7ull, 3}, + {10, 11ull, 3}, {10, 12ull, 3}, {10, 14ull, 3}, {10, 21ull, 3}, {10, 22ull, 3}, {10, 25ull, 3}, + {10, 100ull, 3}, {10, 101ull, 3}, {10, 102ull, 3}, {10, 111ull, 3}, {10, 1000000ull, 3}, + {10, 9223372036854775808ull, 3}, + {11, 0ull, 3}, {11, 1ull, 0}, {11, 2ull, 1}, {11, 3ull, 2}, {11, 4ull, 2}, {11, 5ull, 3}, {11, 7ull, 3}, + {11, 11ull, 3}, {11, 12ull, 3}, {11, 14ull, 3}, {11, 21ull, 3}, {11, 22ull, 3}, {11, 25ull, 3}, + {11, 100ull, 3}, {11, 101ull, 0}, {11, 102ull, 1}, {11, 111ull, 3}, {11, 1000000ull, 3}, + {11, 9223372036854775808ull, 3}, + {12, 0ull, 3}, {12, 1ull, 0}, {12, 2ull, 1}, {12, 3ull, 1}, {12, 4ull, 1}, {12, 5ull, 1}, {12, 7ull, 1}, + {12, 11ull, 3}, {12, 12ull, 3}, {12, 14ull, 3}, {12, 21ull, 0}, {12, 22ull, 1}, {12, 25ull, 1}, + {12, 100ull, 3}, {12, 101ull, 0}, {12, 102ull, 1}, {12, 111ull, 3}, {12, 1000000ull, 3}, + {12, 9223372036854775808ull, 1}, + {13, 0ull, 2}, {13, 1ull, 0}, {13, 2ull, 1}, {13, 3ull, 1}, {13, 4ull, 1}, {13, 5ull, 2}, {13, 7ull, 2}, + {13, 11ull, 2}, {13, 12ull, 2}, {13, 14ull, 2}, {13, 21ull, 2}, {13, 22ull, 1}, {13, 25ull, 2}, + {13, 100ull, 2}, {13, 101ull, 2}, {13, 102ull, 1}, {13, 111ull, 2}, {13, 1000000ull, 2}, + {13, 9223372036854775808ull, 2}, + {14, 0ull, 2}, {14, 1ull, 0}, {14, 2ull, 1}, {14, 3ull, 1}, {14, 4ull, 1}, {14, 5ull, 2}, {14, 7ull, 2}, + {14, 11ull, 2}, {14, 12ull, 2}, {14, 14ull, 2}, {14, 21ull, 0}, {14, 22ull, 1}, {14, 25ull, 2}, + {14, 100ull, 2}, {14, 101ull, 0}, {14, 102ull, 1}, {14, 111ull, 2}, {14, 1000000ull, 2}, + {14, 9223372036854775808ull, 2}, + {15, 0ull, 0}, {15, 1ull, 1}, {15, 2ull, 2}, {15, 3ull, 3}, {15, 4ull, 3}, {15, 5ull, 3}, {15, 7ull, 3}, + {15, 11ull, 4}, {15, 12ull, 4}, {15, 14ull, 4}, {15, 21ull, 4}, {15, 22ull, 4}, {15, 25ull, 4}, + {15, 100ull, 5}, {15, 101ull, 5}, {15, 102ull, 5}, {15, 111ull, 4}, {15, 1000000ull, 5}, + {15, 9223372036854775808ull, 3}, +}; + +constexpr const char* kRefusedRules[] = { + "nplurals=INTEGER; plural=EXPRESSION;", + "nplurals=0; plural=0;", + "nplurals=9; plural=n;", + "nplurals=2;", + "nplurals=2; plural=n ^ 1;", + "nplurals=2; plural=(n != 1;", + "nplurals=2; plural=n != 1 1;", + "nplurals=2; plural=(((((((((((((((((((((((((((((((((n)))))))))))))))))))))))))))))))));", + "nplurals=2; plural=!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!n;", + "nplurals=2; plural=n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+" + "n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n" + "+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n;", + "nplurals=2; plural=12345678901234567890;", +}; diff --git a/src/votv-coop/src/l10n/po_reader.cpp b/src/votv-coop/src/l10n/po_reader.cpp new file mode 100644 index 000000000..438728e14 --- /dev/null +++ b/src/votv-coop/src/l10n/po_reader.cpp @@ -0,0 +1,348 @@ +// l10n/po_reader.cpp -- see l10n/po_reader.h. + +#include "l10n/po_reader.h" + +#include "l10n/printf_check.h" + +#define WIN32_LEAN_AND_MEAN +#include + +#include + +namespace l10n::po { +namespace { + +bool IsSpace(char c) { return c == ' ' || c == '\t' || c == '\r'; } + +// UTF-8 is validated by the platform's own strict decoder with no output buffer: no validator of +// ours, and no dependency on coop/text, which l10n may not reach. +bool ValidUtf8(const std::string& s) { + if (s.empty()) return true; + return ::MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, s.data(), static_cast(s.size()), + nullptr, 0) > 0; +} + +enum class StrResult { Ok, BadEscape, Unterminated }; + +// One quoted string starting at s[i] == '"'; appends its unescaped bytes to `out` and leaves `i` past +// the closing quote, even past a bad escape, so the line's syntax is still read and only the entry is +// refused. The escapes are gettext's: \n \t \r \" \\ \a \b \f \v, octal of one to three digits, hex of +// one or two digits; a longer hex, any other escape, or an escape whose value is 0 (it would cut the C +// string a lookup returns) is a bad escape. +StrResult ReadString(std::string_view s, size_t& i, std::string& out, const char** why) { + ++i; // the opening quote + bool bad = false; + auto badEscape = [&](const char* w) { + if (!bad) *why = w; + bad = true; + }; + while (i < s.size()) { + const char c = s[i++]; + if (c == '"') return bad ? StrResult::BadEscape : StrResult::Ok; + if (c == '\n') return StrResult::Unterminated; + if (c != '\\') { out.push_back(c); continue; } + if (i >= s.size()) return StrResult::Unterminated; + const char e = s[i++]; + switch (e) { + case 'n': out.push_back('\n'); continue; + case 't': out.push_back('\t'); continue; + case 'r': out.push_back('\r'); continue; + case '"': out.push_back('"'); continue; + case '\\': out.push_back('\\'); continue; + case 'a': out.push_back('\a'); continue; + case 'b': out.push_back('\b'); continue; + case 'f': out.push_back('\f'); continue; + case 'v': out.push_back('\v'); continue; + default: break; + } + unsigned v = 0; + if (e >= '0' && e <= '7') { + v = static_cast(e - '0'); + for (int d = 1; d < 3 && i < s.size() && s[i] >= '0' && s[i] <= '7'; ++d) + v = v * 8 + static_cast(s[i++] - '0'); + if (v > 0xFF) { badEscape("an octal escape above \\377"); continue; } + } else if (e == 'x') { + auto hex = [](char h) -> int { + if (h >= '0' && h <= '9') return h - '0'; + if (h >= 'a' && h <= 'f') return h - 'a' + 10; + if (h >= 'A' && h <= 'F') return h - 'A' + 10; + return -1; + }; + int digits = 0; + while (i < s.size() && hex(s[i]) >= 0) { + ++digits; + v = v * 16 + static_cast(hex(s[i++])); + } + if (digits > 2) { badEscape("a hex escape of more than two digits"); continue; } + if (digits == 0) { badEscape("a \\x escape with no digit"); continue; } + } else { + badEscape("an escape outside gettext's set"); + continue; + } + if (v == 0) { badEscape("an escape whose value is 0"); continue; } + out.push_back(static_cast(v)); + } + return StrResult::Unterminated; +} + +// The value of a header field ("Plural-Forms: ..."), trimmed; the header is "Key: value\n" lines. +std::string HeaderField(const std::string& header, const char* key) { + const size_t klen = std::strlen(key); + size_t at = 0; + while (at < header.size()) { + size_t end = header.find('\n', at); + if (end == std::string::npos) end = header.size(); + if (end - at > klen && header.compare(at, klen, key) == 0 && header[at + klen] == ':') { + size_t b = at + klen + 1, e = end; + while (b < e && IsSpace(header[b])) ++b; + while (e > b && IsSpace(header[e - 1])) --e; + return header.substr(b, e - b); + } + at = end + 1; + } + return {}; +} + +bool EqualsNoCase(std::string_view a, std::string_view b) { + if (a.size() != b.size()) return false; + for (size_t i = 0; i < a.size(); ++i) { + char x = a[i], y = b[i]; + if (x >= 'A' && x <= 'Z') x = static_cast(x - 'A' + 'a'); + if (y >= 'A' && y <= 'Z') y = static_cast(y - 'A' + 'a'); + if (x != y) return false; + } + return true; +} + +// The entry being read. `field` is where a continuation string line goes. +struct Building { + Entry e; + bool fuzzy = false; + bool any = false; // a keyword has been read for it + bool sawStr = false; // a msgstr has been read: the next msgctxt/msgid starts a new entry + int plainStr = 0; // msgstr lines without an index + int indexedStr = 0; // msgstr[n] lines + bool bad = false; // refused: an escape, a size, an index + const char* why = nullptr; + int line = 0; + std::string* field = nullptr; +}; + +} // namespace + +File Read(std::string_view bytes) { + File f; + auto refuse = [&](int line, const char* what) { + f.ok = false; + f.refusal = Diag{line, what}; + f.entries.clear(); + f.refused.clear(); + return f; + }; + if (bytes.size() > kMaxFileBytes) return refuse(0, "the file is over 1 MiB"); + if (bytes.size() >= 3 && static_cast(bytes[0]) == 0xEF && + static_cast(bytes[1]) == 0xBB && static_cast(bytes[2]) == 0xBF) + bytes.remove_prefix(3); // a UTF-8 byte order mark + + std::string header; + bool haveHeader = false; + bool pendingFuzzy = false; + Building b; + + auto finish = [&]() -> const char* { + if (!b.any) { b = Building{}; return nullptr; } + Entry& e = b.e; + size_t total = e.ctx.size() + e.id.size() + e.idPlural.size(); + for (const std::string& s : e.str) total += s.size(); + if (!b.bad && total > kMaxEntryBytes) { b.bad = true; b.why = "an entry over 16 KiB"; } + for (const std::string* s : {&e.ctx, &e.id, &e.idPlural}) + if (!ValidUtf8(*s)) return "bytes that are not UTF-8"; + for (const std::string& s : e.str) + if (!ValidUtf8(s)) return "bytes that are not UTF-8"; + if (!b.bad && e.str.empty()) { b.bad = true; b.why = "an entry with no msgstr"; } + // A plural entry has a msgid_plural and only msgstr[n]; a singular one exactly one msgstr. + const bool pluralShape = !e.idPlural.empty() && b.plainStr == 0 && b.indexedStr > 0; + const bool singularShape = e.idPlural.empty() && b.indexedStr == 0 && b.plainStr == 1; + if (!b.bad && !(e.plural ? pluralShape : singularShape)) { + b.bad = true; + b.why = "plural and singular forms mixed in one entry"; + } + const bool isHeader = !e.hasCtx && e.id.empty() && !e.plural; + if (isHeader) { + if (!haveHeader && !b.bad) { header = e.str[0]; haveHeader = true; } + } else if (b.bad) { + f.refused.push_back(Diag{b.line, b.why ? b.why : "a malformed entry"}); + } else if (!b.fuzzy) { + bool allEmpty = true, anyEmpty = false; + for (const std::string& s : e.str) { + if (s.empty()) anyEmpty = true; + else allEmpty = false; + } + if (allEmpty) { + // no translation: the English is shown, and an override's empty entry masks nothing + } else if (anyEmpty) { + f.refused.push_back(Diag{b.line, "a plural entry with an empty form"}); + } else { + // A msgid with a '%' is a format, used through l10n::Fmt: each translation carries its + // conversions (printf_check). One without is plain text drawn as it is, and its + // translation may hold no '%', so no translation can make a format of a plain line. + namespace pc = printf_check; + const bool format = e.id.find('%') != std::string::npos || + e.idPlural.find('%') != std::string::npos; + const char* why = nullptr; + bool good = true; + if (format) { + pc::Parsed mid, other; + good = pc::Parse(e.id, &mid, &why); + if (good && e.plural) + good = pc::Parse(e.idPlural, &other, &why) && pc::Matches(mid, other, &why); + for (size_t k = 0; good && k < e.str.size(); ++k) + good = pc::Parse(e.str[k], &other, &why) && pc::Matches(mid, other, &why); + } else { + for (const std::string& t : e.str) + if (t.find('%') != std::string::npos) { + good = false; + why = "a '%' in the translation of a line that has none"; + } + } + if (good) f.entries.push_back(std::move(e)); + else f.refused.push_back(Diag{b.line, why ? why : "a conversion mismatch"}); + } + } + b = Building{}; + return nullptr; + }; + + int line = 0; + size_t at = 0; + while (at <= bytes.size()) { + size_t end = bytes.find('\n', at); + if (end == std::string_view::npos) end = bytes.size(); + std::string_view ln = bytes.substr(at, end - at); + ++line; + const size_t next = end + 1; + size_t i = 0; + while (i < ln.size() && IsSpace(ln[i])) ++i; + if (i == ln.size()) { at = next; if (end == bytes.size()) break; continue; } + + if (ln[i] == '#') { + // `#, flags`: the next entry is fuzzy when "fuzzy" is among them. Every other comment + // kind -- references, extracted and translator comments, `#|` previous, `#~` obsolete -- + // is ignored. + if (i + 1 < ln.size() && ln[i + 1] == ',') { + if (b.sawStr) { if (const char* w = finish()) return refuse(line, w); } + const std::string_view flags = ln.substr(i + 2); + for (size_t p = 0; (p = flags.find("fuzzy", p)) != std::string_view::npos; p += 5) { + const bool l = p == 0 || flags[p - 1] == ' ' || flags[p - 1] == ',' || flags[p - 1] == '\t'; + const bool r = p + 5 >= flags.size() || flags[p + 5] == ' ' || flags[p + 5] == ',' || + flags[p + 5] == '\r'; + if (l && r) pendingFuzzy = true; + } + } + at = next; + if (end == bytes.size()) break; + continue; + } + + if (ln[i] == '"') { + if (!b.field) return refuse(line, "a string line with no keyword before it"); + const char* why = nullptr; + const StrResult r = ReadString(ln, i, *b.field, &why); + if (r == StrResult::Unterminated) return refuse(line, "a string that never closes"); + if (r == StrResult::BadEscape && !b.bad) { b.bad = true; b.why = why; } + } else { + size_t k = i; + while (k < ln.size() && !IsSpace(ln[k]) && ln[k] != '"') ++k; + const std::string_view kw = ln.substr(i, k - i); + std::string* target = nullptr; + if (kw == "msgctxt" || kw == "msgid") { + if (b.sawStr || (kw == "msgctxt" && b.any) || (kw == "msgid" && !b.e.id.empty())) { + if (const char* w = finish()) return refuse(line, w); + } + if (!b.any) { b.fuzzy = pendingFuzzy; pendingFuzzy = false; b.line = line; } + b.any = true; + if (kw == "msgctxt") { b.e.hasCtx = true; target = &b.e.ctx; } + else { target = &b.e.id; b.line = line; } + } else if (kw == "msgid_plural") { + if (!b.any) return refuse(line, "a msgid_plural with no msgid"); + b.e.plural = true; + target = &b.e.idPlural; + } else if (kw == "msgstr") { + if (!b.any) return refuse(line, "a msgstr with no msgid"); + b.sawStr = true; + ++b.plainStr; + b.e.str.emplace_back(); + target = &b.e.str.back(); + } else if (kw.size() > 8 && kw.substr(0, 7) == "msgstr[" && kw.back() == ']') { + if (!b.any) return refuse(line, "a msgstr with no msgid"); + b.sawStr = true; + ++b.indexedStr; + size_t idx = 0; + bool num = true; + for (char c : kw.substr(7, kw.size() - 8)) { + if (c < '0' || c > '9') { num = false; break; } + idx = idx * 10 + static_cast(c - '0'); + if (idx > kMaxForms) break; + } + if (!num || idx != b.e.str.size()) { + if (!b.bad) { b.bad = true; b.why = "plural forms out of order"; } + } else if (idx >= kMaxForms) { + if (!b.bad) { b.bad = true; b.why = "more than 8 plural forms"; } + } + b.e.str.emplace_back(); + target = &b.e.str.back(); + } else { + return refuse(line, "a line that is neither a keyword, a string nor a comment"); + } + i = k; + while (i < ln.size() && IsSpace(ln[i])) ++i; + if (i >= ln.size() || ln[i] != '"') return refuse(line, "a keyword without its string"); + b.field = target; + const char* why = nullptr; + const StrResult r = ReadString(ln, i, *target, &why); + if (r == StrResult::Unterminated) return refuse(line, "a string that never closes"); + if (r == StrResult::BadEscape && !b.bad) { b.bad = true; b.why = why; } + } + // gettext's lexer reads strings as tokens, so a second string on the same line joins the first. + for (;;) { + while (i < ln.size() && IsSpace(ln[i])) ++i; + if (i >= ln.size() || ln[i] != '"') break; + const char* why = nullptr; + const StrResult r = ReadString(ln, i, *b.field, &why); + if (r == StrResult::Unterminated) return refuse(line, "a string that never closes"); + if (r == StrResult::BadEscape && !b.bad) { b.bad = true; b.why = why; } + } + // Nothing but blanks may follow a string on its line. + while (i < ln.size() && IsSpace(ln[i])) ++i; + if (i != ln.size()) return refuse(line, "text after a string on its line"); + at = next; + if (end == bytes.size()) break; + } + if (const char* w = finish()) return refuse(line, w); + + if (haveHeader) { + f.pluralForms = HeaderField(header, "Plural-Forms"); + f.language = HeaderField(header, "Language"); + f.lastTranslator = HeaderField(header, "Last-Translator"); + f.languageTeam = HeaderField(header, "Language-Team"); + const std::string ct = HeaderField(header, "Content-Type"); + const size_t cs = ct.find("charset="); + if (cs == std::string::npos) { + f.charsetAssumed = true; + } else { + std::string_view v = std::string_view(ct).substr(cs + 8); + const size_t semi = v.find(';'); + if (semi != std::string_view::npos) v = v.substr(0, semi); + while (!v.empty() && IsSpace(v.back())) v.remove_suffix(1); + if (v == "CHARSET") f.charsetAssumed = true; + else if (!EqualsNoCase(v, "UTF-8") && !EqualsNoCase(v, "UTF8")) + return refuse(0, "a charset other than UTF-8"); + } + } else { + f.charsetAssumed = true; + } + f.ok = true; + return f; +} + +} // namespace l10n::po diff --git a/src/votv-coop/src/l10n/printf_check.cpp b/src/votv-coop/src/l10n/printf_check.cpp new file mode 100644 index 000000000..a8be3f38f --- /dev/null +++ b/src/votv-coop/src/l10n/printf_check.cpp @@ -0,0 +1,129 @@ +// l10n/printf_check.cpp -- see l10n/printf_check.h. + +#include "l10n/printf_check.h" + +namespace l10n::printf_check { +namespace { + +bool IsDigit(char c) { return c >= '0' && c <= '9'; } +bool IsIntType(char t) { + return t == 'd' || t == 'i' || t == 'u' || t == 'x' || t == 'X' || t == 'o'; +} +bool IsFloatType(char t) { + return t == 'f' || t == 'F' || t == 'e' || t == 'E' || t == 'g' || t == 'G'; +} +bool IsType(char t) { return IsIntType(t) || IsFloatType(t) || t == 's'; } +// `d` and `i` read the same argument the same way; every other type is its own. +char TypeClass(char t) { return t == 'i' ? 'd' : t; } + +} // namespace + +bool Parse(std::string_view s, Parsed* out, const char** why) { + *out = Parsed{}; + auto fail = [&](const char* w) { if (why) *why = w; return false; }; + if (s.size() > 0xFFFF) return fail("a format longer than 64 KiB"); + int numbered = -1; // unknown until the first conversion: 1 numbered, 0 unnumbered + for (size_t i = 0; i < s.size(); ++i) { + if (s[i] != '%') continue; + const size_t begin = i++; + if (i >= s.size()) return fail("a '%' at the end of the format"); + if (s[i] == '%') continue; + Conv c; + c.begin = static_cast(begin); + // An argument number is digits followed by '$'; anything else is the width or a flag. + size_t j = i; + unsigned num = 0; + while (j < s.size() && IsDigit(s[j]) && j - i < 3) num = num * 10 + static_cast(s[j++] - '0'); + const bool hasNum = j > i && j < s.size() && s[j] == '$'; + if (hasNum) { + if (num == 0 || num > kMaxConvs) return fail("an argument number outside 1..16"); + if (numbered == 0) return fail("numbered and unnumbered conversions mixed"); + numbered = 1; + c.arg = static_cast(num); + i = j + 1; + } else { + if (numbered == 1) return fail("numbered and unnumbered conversions mixed"); + numbered = 0; + c.arg = static_cast(out->count + 1); + } + c.specBegin = static_cast(i); + bool zero = false, sign = false, alt = false, prec = false; + for (; i < s.size(); ++i) { + const char g = s[i]; + if (g == '0') zero = true; + else if (g == '+' || g == ' ') sign = true; + else if (g == '#') alt = true; + else if (g != '-') break; + } + if (i < s.size() && s[i] == '*') return fail("a '*' width or precision"); + for (int d = 0; i < s.size() && IsDigit(s[i]); ++d, ++i) + if (d == 2) return fail("a width of more than two digits"); + if (i < s.size() && s[i] == '.') { + ++i; + prec = true; + if (i < s.size() && s[i] == '*') return fail("a '*' width or precision"); + for (int d = 0; i < s.size() && IsDigit(s[i]); ++d, ++i) + if (d == 2) return fail("a precision of more than two digits"); + } + if (i < s.size() && s[i] == 'l') { + ++i; + c.len = Len::L; + if (i < s.size() && s[i] == 'l') { ++i; c.len = Len::LL; } + } else if (i < s.size() && s[i] == 'z') { + ++i; + c.len = Len::Z; + } + if (i >= s.size()) return fail("a conversion without its type"); + const char t = s[i]; + if (t == 'n') return fail("a '%n' conversion"); + if (!IsType(t)) return fail("a conversion type outside d i u x X o f F e E g G s"); + if (c.len != Len::None && !IsIntType(t)) return fail("a length modifier on a non-integer type"); + // C leaves these pairings undefined; a precision on %s counts bytes and would cut a name. + if (zero && t == 's') return fail("a '0' flag on %s"); + if (sign && !(t == 'd' || t == 'i' || IsFloatType(t))) + return fail("a '+' or ' ' flag on an unsigned or string type"); + if (alt && !(t == 'x' || t == 'X' || t == 'o' || IsFloatType(t))) + return fail("a '#' flag on a type it has no form for"); + if (prec && t == 's') return fail("a precision on %s"); + c.type = t; + c.end = static_cast(i + 1); + if (out->count == kMaxConvs) return fail("more than 16 conversions"); + out->convs[out->count++] = c; + } + // Each argument exactly once: unnumbered is so by construction; numbered must cover 1..k. + uint32_t seen = 0; + unsigned top = 0; + for (uint8_t k = 0; k < out->count; ++k) { + const unsigned a = out->convs[k].arg; + if (seen & (1u << a)) return fail("an argument read twice"); + seen |= 1u << a; + if (a > top) top = a; + } + if (top != out->count) return fail("a gap in the argument numbers"); + out->args = out->count; + return true; +} + +const Conv* ForArg(const Parsed& p, uint8_t arg) { + for (uint8_t k = 0; k < p.count; ++k) + if (p.convs[k].arg == arg) return &p.convs[k]; + return nullptr; +} + +bool Matches(const Parsed& msgid, const Parsed& tr, const char** why) { + if (msgid.args != tr.args) { + if (why) *why = "a different number of conversions than the msgid"; + return false; + } + for (uint8_t a = 1; a <= msgid.args; ++a) { + const Conv* m = ForArg(msgid, a); + const Conv* t = ForArg(tr, a); + if (!m || !t || TypeClass(m->type) != TypeClass(t->type) || m->len != t->len) { + if (why) *why = "a conversion whose type or length differs from the msgid's"; + return false; + } + } + return true; +} + +} // namespace l10n::printf_check From 75276d2ebb90c54e452dbecd9448b49428e8f2d6 Mon Sep 17 00:00:00 2001 From: pelmentor Date: Sat, 10 Oct 2026 05:29:25 +0600 Subject: [PATCH 077/306] [ui] the F1 developer panes move to their own file A pure move: the twelve developer panes and the two lines and the search helper only they use leave ui/dev_menu.cpp for ui/dev_panes, and the menu's tree names them there. The menu keeps its tree, its window and the player panes, so a file is now either text a player reads or a developer's controls, which the translation layer's per-file gate needs; the menu also drops from 755 lines to 394. Co-Authored-By: Claude Opus 5.5 --- src/votv-coop/include/ui/dev_panes.h | 23 ++ src/votv-coop/sources.cmake | 1 + src/votv-coop/src/ui/dev_menu.cpp | 381 +------------------------- src/votv-coop/src/ui/dev_panes.cpp | 384 +++++++++++++++++++++++++++ 4 files changed, 418 insertions(+), 371 deletions(-) create mode 100644 src/votv-coop/include/ui/dev_panes.h create mode 100644 src/votv-coop/src/ui/dev_panes.cpp diff --git a/src/votv-coop/include/ui/dev_panes.h b/src/votv-coop/include/ui/dev_panes.h new file mode 100644 index 000000000..1ce9588d8 --- /dev/null +++ b/src/votv-coop/include/ui/dev_panes.h @@ -0,0 +1,23 @@ +// ui/dev_panes.h -- the F1 menu's developer panes: the controls behind the dev-flagged entries of the +// menu's tree (ui/dev_menu.cpp), which hides them unless the dev switch is on. Kept apart from the menu +// and its player panes, which are translated, so a file is either the player's text or a developer's. +// Render thread, inside the overlay's frame, as the menu draws them. + +#pragma once + +namespace ui::dev_panes { + +void RenderSnow(); +void RenderTeleportClients(); +void RenderFreecam(); +void RenderRestoreVitals(); +void RenderSetClock(); +void RenderPosHud(); +void RenderObjectOverlay(); +void RenderRagdollBones(); +void RenderSpawnNpc(); +void RenderGivePoints(); +void RenderSpawnMenuUnlock(); +void RenderEvents(); + +} // namespace ui::dev_panes diff --git a/src/votv-coop/sources.cmake b/src/votv-coop/sources.cmake index 55771f42c..259a864cd 100644 --- a/src/votv-coop/sources.cmake +++ b/src/votv-coop/sources.cmake @@ -651,6 +651,7 @@ set(VOTVCOOP_SOURCES src/ui/overlay_backend_dx12.cpp src/ui/overlay_backend_dx12_capture.cpp src/ui/dev_menu.cpp + src/ui/dev_panes.cpp src/ui/skins_panel.cpp src/ui/admin_panel.cpp src/ui/bug_report_pane.cpp diff --git a/src/votv-coop/src/ui/dev_menu.cpp b/src/votv-coop/src/ui/dev_menu.cpp index 6e7b54ff2..63587fdbc 100644 --- a/src/votv-coop/src/ui/dev_menu.cpp +++ b/src/votv-coop/src/ui/dev_menu.cpp @@ -1,24 +1,11 @@ // ui/dev_menu.cpp -- see ui/dev_menu.h. #include "ui/dev_menu.h" +#include "ui/dev_panes.h" #include "coop/dev/dev_gate.h" -#include "coop/dev/event_force.h" -#include "coop/dev/event_trigger.h" -#include "coop/dev/force_weather.h" -#include "coop/dev/mannequin_drill.h" -#include "coop/dev/freecam.h" -#include "coop/dev/object_overlay.h" -#include "coop/dev/ragdoll_bone_overlay.h" -#include "coop/dev/pos_hud.h" -#include "coop/dev/add_points.h" -#include "coop/dev/restore_vitals.h" -#include "coop/dev/set_clock.h" -#include "coop/dev/spawn_menu_unlock.h" -#include "coop/dev/spawn_npc.h" #include "coop/permissions/grants_core.h" #include "coop/session/local_grants.h" -#include "coop/session/teleport_client.h" #include "coop/comms/peer_action_feed.h" #include "coop/player/nameplate.h" #include "coop/player/nick_color.h" @@ -57,354 +44,6 @@ struct Item { void (*render)(); bool dev; }; struct Sub { const char* name; std::vector items; bool dev; bool host = false; }; struct Cat { const char* name; std::vector subs; bool dev; bool host = false; }; -// ---- feature controls (each calls a plain function its module exposes) ------- - -// A world pane writes the shared world: host authority (coop/dev/dev_gate). A local pane touches only -// this machine: the host's grant (coop/session/local_grants). -constexpr const char* kHostOnlyLine = "Only the host can do this."; -constexpr const char* kNotAllowedLine = "The host has not allowed this."; - -void RenderSnow() { - if (!coop::dev_gate::Allowed()) { - ImGui::TextDisabled("%s", kHostOnlyLine); - return; - } - bool on = coop::dev::force_weather::IsSnowOn(); - // Host-authoritative: the toggle is a no-op on a client (the module gates it). - if (ImGui::Checkbox("Snow", &on)) coop::dev::force_weather::SetSnow(on); - ImGui::SameLine(); - ImGui::TextDisabled("(host-authoritative; clients mirror)"); -} - -void RenderTeleportClients() { - if (!coop::dev_gate::Allowed()) { - ImGui::TextDisabled("%s", kHostOnlyLine); - return; - } - if (ImGui::Button("Teleport clients to me")) coop::teleport_client::TeleportClientsToHost(); - ImGui::SameLine(); - ImGui::TextDisabled("(host only)"); -} - -void RenderFreecam() { - if (!coop::session::local_grants::Has(coop::permissions::grants::Projected::Freecam)) { - ImGui::TextDisabled("%s", kNotAllowedLine); - return; - } - bool on = coop::dev::freecam::IsActive(); - if (ImGui::Checkbox("Freecam", &on)) coop::dev::freecam::SetActive(on); - ImGui::TextDisabled("HOME also toggles - WASD move, Space/Ctrl up/down,"); - ImGui::TextDisabled("Shift fast, wheel speed"); - // The middle-click self-teleport moves the pawn in the shared world: the host's alone. - if (coop::dev_gate::Allowed()) ImGui::TextDisabled("MMB bring player"); -} - -void RenderRestoreVitals() { - if (coop::dev_gate::Allowed()) { - if (ImGui::Button("Restore vitals (food / sleep / health)")) coop::dev::restore_vitals::Restore(); - ImGui::SameLine(); - ImGui::TextDisabled("(both peers)"); - } else { - ImGui::TextDisabled("%s", kHostOnlyLine); - } - if (coop::session::local_grants::Has(coop::permissions::grants::Projected::Stamina)) { - if (ImGui::Button("Set stamina low")) coop::dev::restore_vitals::SetStaminaLow(); - ImGui::SameLine(); - ImGui::TextDisabled("(local test -- sleep/energy=10 -> exhausted; nameplate syncs)"); - } else { - ImGui::TextDisabled("%s", kNotAllowedLine); - } -} - -void RenderSetClock() { - if (!coop::dev_gate::Allowed()) { - ImGui::TextDisabled("%s", kHostOnlyLine); - return; - } - namespace SC = coop::dev::set_clock; - int hour = 0, minute = 0, day = 0; - float frac = 0.f; - if (!SC::ReadCurrent(hour, minute, day, frac)) { - ImGui::TextDisabled("World clock not resolved yet (enter a world)."); - return; - } - ImGui::TextDisabled("Host-authoritative; the clock (timeZ -> settime) drives events + the save."); - ImGui::Text("Now: Day %d — %02d:%02d", day, hour, minute); - ImGui::SameLine(); - ImGui::TextDisabled("(sun %.3f)", frac); - static int s_day = -1, s_hour = -1, s_min = -1; - if (s_day < 0) { s_day = day; s_hour = hour; s_min = minute; } // seed once from the live clock - ImGui::SetNextItemWidth(S(90.f)); - ImGui::InputInt("day##clk", &s_day); - ImGui::SameLine(); - ImGui::SetNextItemWidth(S(74.f)); - ImGui::InputInt("h##clk", &s_hour); - ImGui::SameLine(); - ImGui::SetNextItemWidth(S(74.f)); - ImGui::InputInt("m##clk", &s_min); - if (s_day < 1) s_day = 1; - if (s_hour < 0) s_hour = 0; - if (s_hour > 23) s_hour = 23; - if (s_min < 0) s_min = 0; - if (s_min > 59) s_min = 59; - ImGui::SameLine(); - if (ImGui::Button("Set clock")) SC::SetClock(s_day, s_hour, s_min); - if (ImGui::IsItemHovered()) - ImGui::SetTooltip("Writes the game's own running clock (timeZ); the next minute pulse runs\n" - "saveSlot.settime natively. Jumping the day FORWARD fires every skipped\n" - "scheduled story event at once (native settime behavior; they mirror to\n" - "clients via EventFire). Day is the DISPLAYED day (as on the save rows)."); - ImGui::SameLine(); - ImGui::TextDisabled("(host only)"); - float f = frac; - ImGui::SetNextItemWidth(S(260.f)); - if (ImGui::SliderFloat("Sun position", &f, 0.0f, 0.999f, "%.3f")) - SC::SetTimeFraction(f); // live: drag moves the sun (totalTime := frac * MaxTime) - ImGui::TextDisabled("Visual only (sun angle). The clock above is what events/save follow."); -} - -void RenderPosHud() { - if (!coop::session::local_grants::Has(coop::permissions::grants::Projected::Hud)) { - ImGui::TextDisabled("%s", kNotAllowedLine); - return; - } - bool on = coop::dev::pos_hud::IsVisible(); - if (ImGui::Checkbox("Position / camera readout", &on)) coop::dev::pos_hud::SetVisible(on); - ImGui::SameLine(); - ImGui::TextDisabled("(on-screen overlay)"); -} - -void RenderObjectOverlay() { - if (!coop::session::local_grants::Has(coop::permissions::grants::Projected::Overlay)) { - ImGui::TextDisabled("%s", kNotAllowedLine); - return; - } - namespace OO = coop::dev::object_overlay; - bool on = OO::IsEnabled(); - if (ImGui::Checkbox("World object overlay", &on)) OO::SetEnabled(on); - ImGui::SameLine(); - ImGui::TextDisabled("(labels projected onto nearby objects)"); - ImGui::Indent(S(22.f)); - ImGui::BeginDisabled(!on); - bool names = OO::LayerNames(); - if (ImGui::Checkbox("Object names", &names)) OO::SetLayerNames(names); - ImGui::SameLine(); - ImGui::TextDisabled("(class + prop visual row)"); - bool net = OO::LayerNet(); - if (ImGui::Checkbox("Net identity", &net)) OO::SetLayerNet(net); - ImGui::SameLine(); - ImGui::TextDisabled("(eid, mirror/local/untracked, key)"); - bool phys = OO::LayerPhys(); - if (ImGui::Checkbox("Physics state", &phys)) OO::SetLayerPhys(phys); - ImGui::SameLine(); - ImGui::TextDisabled("(sim/rest + static/frozen/sleep)"); - bool hp = OO::LayerHealth(); - if (ImGui::Checkbox("Health / progress", &hp)) OO::SetLayerHealth(hp); - ImGui::SameLine(); - ImGui::TextDisabled("(creature+prop hp; decal clean/cement pools)"); - float r = OO::RadiusM(); - ImGui::SetNextItemWidth(S(170.f)); - if (ImGui::SliderFloat("Radius (m)", &r, 5.f, 100.f, "%.0f")) OO::SetRadiusM(r); - ImGui::EndDisabled(); - ImGui::Unindent(S(22.f)); -} - -void RenderRagdollBones() { - if (!coop::session::local_grants::Has(coop::permissions::grants::Projected::Overlay)) { - ImGui::TextDisabled("%s", kNotAllowedLine); - return; - } - namespace RB = coop::dev::ragdoll_bone_overlay; - bool on = RB::IsEnabled(); - if (ImGui::Checkbox("Ragdoll bone skeleton", &on)) RB::SetEnabled(on); - ImGui::SameLine(); - ImGui::TextDisabled("(lines between every bone of an ACTIVE ragdoll body)"); - ImGui::TextDisabled("The native ragdoll (C key / faint / trip) is an invisible separate actor;"); - ImGui::TextDisabled("orange = your own ragdoll, cyan = a remote peer's mirror body."); -} - -void RenderSpawnNpc() { - if (!coop::dev_gate::Allowed()) { - ImGui::TextDisabled("%s", kHostOnlyLine); - return; - } - if (ImGui::Button("Spawn kerfurOmega (in front)")) coop::dev::spawn_npc::SpawnKerfurOmega(); - ImGui::SameLine(); - ImGui::TextDisabled("(host spawns + syncs)"); - // Spawn the allowlisted creatures directly, in front of the player: the wisp event arms an - // overlap box, and the eventer's ventCrawler lands about ten metres away in a vent (see - // spawn_npc). - if (ImGui::Button("Spawn killerWisp (in front)")) coop::dev::spawn_npc::SpawnKillerWisp(); - ImGui::SameLine(); - if (ImGui::Button("Spawn ventCrawler (in front)")) coop::dev::spawn_npc::SpawnVentCrawler(); - ImGui::SameLine(); - ImGui::TextDisabled("(mirror test: watch the client radar)"); - // Killer-wisp cross-peer kill test: spawn the wisp ON a client puppet so it grabs the - // CLIENT, routing the kill there, instead of the nearest, which is the host. - if (ImGui::Button("Spawn killerWisp ON client (coop kill test)")) - coop::dev::spawn_npc::SpawnKillerWispOnClient(); - ImGui::Separator(); - // Owner-entity lane test: an eyer is per-peer OWNED, its native AI stalking the spawning - // peer, and cross-peer VISIBLE, other peers getting a brain-parked display mirror. - if (ImGui::Button("Spawn Eyer (in front)")) coop::dev::spawn_npc::SpawnEyer(); - ImGui::SameLine(); - ImGui::TextDisabled("(owner-entity test: it stalks YOU; peers see a harmless mirror)"); - if (ImGui::IsItemHovered()) - ImGui::SetTooltip("The night 'Eyes' stalker. Each peer keeps its OWN native eyer\n" - "(it targets the peer that rolled it); every other peer renders a\n" - "display mirror -- AI and killsphere disabled on the mirror."); - if (ImGui::Button("Spawn walking mannequin")) coop::dev::mannequin_drill::SpawnWalker(); - ImGui::SameLine(); - ImGui::TextDisabled("(the game's own creature: use a throwaway save)"); - if (ImGui::IsItemHovered()) - ImGui::SetTooltip("The game's walking mannequin, spawned at its nearest spawn point that is not on screen " - "(it normally appears from day 14).\n" - "It opens doors, is saved with the world and returns angry after a load.\n" - "In a session it crosses to each client as a prop mirror that runs a brain of its own."); -} - -void RenderGivePoints() { - if (!coop::dev_gate::Allowed()) { - ImGui::TextDisabled("%s", kHostOnlyLine); - return; - } - if (ImGui::Button("+1000 Points")) coop::dev::add_points::GivePoints(1000); - ImGui::SameLine(); - ImGui::TextDisabled("(local balance -- afford drone orders)"); -} - -void RenderSpawnMenuUnlock() { - if (!coop::dev_gate::Allowed()) { - ImGui::TextDisabled("%s", kHostOnlyLine); - return; - } - namespace SM = coop::dev::spawn_menu_unlock; - bool on = SM::IsEnabled(); - if (ImGui::Checkbox("Prop spawn menu in story mode (Q)", &on)) SM::SetEnabled(on); - ImGui::SameLine(); - ImGui::TextDisabled("(host/local only)"); - ImGui::Indent(S(22.f)); - ImGui::TextDisabled("Enables the sandbox Q spawn menu in story mode."); - if (ImGui::Button("Open spawn menu now")) SM::OpenNow(); - ImGui::SameLine(); - ImGui::TextDisabled("(or press Q while enabled)"); - ImGui::Unindent(S(22.f)); -} - -// Case-insensitive substring (the filter box; avoids imgui_internal.h). -bool StrContainsI(const char* hay, const char* needle) { - if (!needle[0]) return true; - for (const char* h = hay; *h; ++h) { - const char* a = h; - const char* b = needle; - while (*a && *b && (tolower(static_cast(*a)) == - tolower(static_cast(*b)))) { ++a; ++b; } - if (!*b) return true; - } - return false; -} - -void RenderEvents() { - if (!coop::dev_gate::Allowed()) { - ImGui::TextDisabled("%s", kHostOnlyLine); - return; - } - namespace ET = coop::dev::event_trigger; - namespace EF = coop::dev::event_force; - EF::RequestRefresh(); // ~1 Hz internal limiter; keeps the ARMED badges live while the tab is open - ImGui::TextDisabled("Trigger any game event (host only; runEvent + runSpecialEvent + ambient verbs)."); - ImGui::TextDisabled("Grouped by strict"); - ImGui::TextDisabled("category. 'day N' = unlock day. The cyan TIME column is the native trigger time-of-"); - ImGui::TextDisabled("day (HH:MM anchor) / 'trigger' (story/build) / 'rep' (ariral-reputation prank pool)."); - ImGui::TextDisabled("Volume-gated events show a badge: fire only ARMS a level volume; NOW! completes it"); - ImGui::TextDisabled("instantly (drives the volume's own overlap with your pawn -- the native walk-in)."); - static char filter[32] = {}; - ImGui::SetNextItemWidth(S(180.f)); - ImGui::InputTextWithHint("##evfilter", "filter (name / category / time / effect)...", filter, sizeof(filter)); - ImGui::Separator(); - // Horizontal scrollbar: the effect column can run past the panel on a narrow window, so the - // reader scrolls right rather than losing the text. The window itself is wider and - // min-constrained too. - ImGui::BeginChild("##evlist", ImVec2(0, 0), ImGuiChildFlags_None, ImGuiWindowFlags_HorizontalScrollbar); - ET::Category lastCat = ET::Category::COUNT; - bool haveCat = false; - for (const auto& ev : ET::Events()) { - const char* catName = ET::CategoryName(ev.cat); - if (filter[0] && !StrContainsI(ev.name, filter) && !StrContainsI(catName, filter) && - !StrContainsI(ev.time, filter) && !StrContainsI(ev.mechanism, filter)) - continue; - if (!haveCat || ev.cat != lastCat) { - lastCat = ev.cat; - haveCat = true; - ImGui::SeparatorText(catName); - } - const bool danger = ev.risk == ET::Risk::Dangerous; - if (danger) - ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.0f, 0.45f, 0.40f, 1.0f)); - else if (ev.risk == ET::Risk::Caution) - ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.0f, 0.85f, 0.45f, 1.0f)); - const bool clicked = ImGui::Button(ev.name, ImVec2(S(150.f), 0)); - if (ev.risk != ET::Risk::Safe) ImGui::PopStyleColor(); - const bool hoveredBtn = ImGui::IsItemHovered(); - ImGui::SameLine(); - if (ev.dayZ >= 0) ImGui::TextDisabled("day %-3d", ev.dayZ); - else ImGui::TextDisabled(" - "); - ImGui::SameLine(); - // The native trigger TIME (hours:minutes) -- the user's key ask. Cyan so it stands out. - ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.55f, 0.85f, 0.95f, 1.0f)); - ImGui::Text("%-7s", ev.time); - ImGui::PopStyleColor(); - // Volume-gated events: the live gate badge + the force-NOW button. - const EF::BoxStatus box = EF::StatusFor(ev.name); - bool forceClicked = false, forceHovered = false; - if (box.hasBox) { - ImGui::SameLine(); - if (!box.resolved) - ImGui::TextDisabled("[gate: volume ?]"); - else if (box.shots == 0) - ImGui::TextColored(ImVec4(0.5f, 0.9f, 0.5f, 1.0f), "[FIRED]"); - else if (box.armed) - ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.3f, 1.0f), "[ARMED - walk-in pending]"); - else - ImGui::TextDisabled("[volume-gated]"); - ImGui::SameLine(); - ImGui::PushID(ev.name); - forceClicked = ImGui::SmallButton("NOW!"); - forceHovered = ImGui::IsItemHovered(); - ImGui::PopID(); - } - ImGui::SameLine(); - ImGui::TextDisabled("%s", ev.mechanism); - const char* note = box.hasBox ? nullptr : EF::GateNote(ev.name); - if (forceHovered) { - ImGui::SetTooltip("Arm + complete NOW: fires the event (clients get the arm as usual), then\n" - "drives %s's own overlap handler with your pawn -- the same dispatch a real\n" - "walk-in performs (native class filter + N bookkeeping).%s", - box.boxName, danger ? "\n\nDangerous event: Ctrl+click." : ""); - } else if (hoveredBtn) { - if (danger) { - ImGui::SetTooltip("%s | native time: %s\n%s%s%s%s\n\nStory/save progression or player relocation" - " --\ncan desync the run. Ctrl+click to trigger.", catName, ev.time, ev.mechanism, - (box.hasBox || note) ? "\ngate: " : "", - box.hasBox ? "fire ARMS the level volume " : (note ? note : ""), - box.hasBox ? box.boxName : ""); - } else { - const char* path = (ev.dispatch == ET::Dispatch::SpecialEvent) ? "runSpecialEvent (specific prank)" - : (ev.dispatch == ET::Dispatch::RandomPrank) ? "runEvent (RANDOM rep-tier prank)" - : (ev.dispatch == ET::Dispatch::Ambient) ? "direct ambient UFunction (daynight/gamemode)" - : "runEvent"; - ImGui::SetTooltip("%s | native time: %s | via %s\n%s%s%s%s", catName, ev.time, path, ev.mechanism, - (box.hasBox || note) ? "\ngate: " : "", - box.hasBox ? "fire ARMS the level volume; walk into it (or NOW!) to complete: " : (note ? note : ""), - box.hasBox ? box.boxName : ""); - } - } - if (clicked && (!danger || ImGui::GetIO().KeyCtrl)) ET::Trigger(ev); - if (forceClicked && (!danger || ImGui::GetIO().KeyCtrl)) EF::ForceNow(ev.name); - } - ImGui::EndChild(); -} - // Skins: the model browser. Its own panel file -- the tiles and the preview cache live in // ui/skins_panel.cpp; this is just the tree hook. void RenderSkins() { ui::skins_panel::Render(); } @@ -568,27 +207,27 @@ void RenderWorldRules() { ui::world_rules_panel::Render(); } const std::vector& Tree() { static const std::vector kTree = { { "Player", { - { "Movement", { { &RenderTeleportClients, true }, { &RenderFreecam, true } }, true }, - { "Vitals", { { &RenderRestoreVitals, true } }, true }, - { "HUD", { { &RenderPosHud, true }, { &RenderObjectOverlay, true }, { &RenderRagdollBones, true } }, true }, + { "Movement", { { &ui::dev_panes::RenderTeleportClients, true }, { &ui::dev_panes::RenderFreecam, true } }, true }, + { "Vitals", { { &ui::dev_panes::RenderRestoreVitals, true } }, true }, + { "HUD", { { &ui::dev_panes::RenderPosHud, true }, { &ui::dev_panes::RenderObjectOverlay, true }, { &ui::dev_panes::RenderRagdollBones, true } }, true }, }, true }, { "World", { // Rules is for EVERYONE (host+clients+solo): a read-only view of the // world rules this peer runs under (mainGameInstance.gameRules) + // gamemode. Non-dev, non-host. { "Rules", { { &RenderWorldRules, false } }, false }, - { "Weather", { { &RenderSnow, true } }, true }, - { "Clock", { { &RenderSetClock, true } }, true }, - { "Economy", { { &RenderGivePoints, true } }, true }, + { "Weather", { { &ui::dev_panes::RenderSnow, true } }, true }, + { "Clock", { { &ui::dev_panes::RenderSetClock, true } }, true }, + { "Economy", { { &ui::dev_panes::RenderGivePoints, true } }, true }, }, false }, { "Content", { // The game's spawnable/triggerable content. Entities = creature/entity // test spawns (the owner-entity eyer included); Props = prop-content // tools, the Q spawn-menu unlock among them, which is its own subsection // because it spawns PROPS rather than entities; Events = the event board. - { "Entities", { { &RenderSpawnNpc, true } }, true }, - { "Props", { { &RenderSpawnMenuUnlock, true } }, true }, - { "Events", { { &RenderEvents, true } }, true }, + { "Entities", { { &ui::dev_panes::RenderSpawnNpc, true } }, true }, + { "Props", { { &ui::dev_panes::RenderSpawnMenuUnlock, true } }, true }, + { "Events", { { &ui::dev_panes::RenderEvents, true } }, true }, }, true }, { "Network", { // Stats is for EVERYONE (the overlay toggle + live session readout); diff --git a/src/votv-coop/src/ui/dev_panes.cpp b/src/votv-coop/src/ui/dev_panes.cpp new file mode 100644 index 000000000..cbcf20456 --- /dev/null +++ b/src/votv-coop/src/ui/dev_panes.cpp @@ -0,0 +1,384 @@ +// ui/dev_panes.cpp -- see ui/dev_panes.h. + +#include "ui/dev_panes.h" + +#include "coop/dev/dev_gate.h" +#include "coop/dev/event_force.h" +#include "coop/dev/event_trigger.h" +#include "coop/dev/force_weather.h" +#include "coop/dev/mannequin_drill.h" +#include "coop/dev/freecam.h" +#include "coop/dev/object_overlay.h" +#include "coop/dev/ragdoll_bone_overlay.h" +#include "coop/dev/pos_hud.h" +#include "coop/dev/add_points.h" +#include "coop/dev/restore_vitals.h" +#include "coop/dev/set_clock.h" +#include "coop/dev/spawn_menu_unlock.h" +#include "coop/dev/spawn_npc.h" +#include "coop/permissions/grants_core.h" +#include "coop/session/local_grants.h" +#include "coop/session/teleport_client.h" +#include "ui/scale.h" + +#include +#include +#include +#include +#include + +#include "imgui.h" + +namespace ui::dev_panes { + +using ui::scale::S; + +// ---- feature controls (each calls a plain function its module exposes) ------- + +// A world pane writes the shared world: host authority (coop/dev/dev_gate). A local pane touches only +// this machine: the host's grant (coop/session/local_grants). +constexpr const char* kHostOnlyLine = "Only the host can do this."; +constexpr const char* kNotAllowedLine = "The host has not allowed this."; + +void RenderSnow() { + if (!coop::dev_gate::Allowed()) { + ImGui::TextDisabled("%s", kHostOnlyLine); + return; + } + bool on = coop::dev::force_weather::IsSnowOn(); + // Host-authoritative: the toggle is a no-op on a client (the module gates it). + if (ImGui::Checkbox("Snow", &on)) coop::dev::force_weather::SetSnow(on); + ImGui::SameLine(); + ImGui::TextDisabled("(host-authoritative; clients mirror)"); +} + +void RenderTeleportClients() { + if (!coop::dev_gate::Allowed()) { + ImGui::TextDisabled("%s", kHostOnlyLine); + return; + } + if (ImGui::Button("Teleport clients to me")) coop::teleport_client::TeleportClientsToHost(); + ImGui::SameLine(); + ImGui::TextDisabled("(host only)"); +} + +void RenderFreecam() { + if (!coop::session::local_grants::Has(coop::permissions::grants::Projected::Freecam)) { + ImGui::TextDisabled("%s", kNotAllowedLine); + return; + } + bool on = coop::dev::freecam::IsActive(); + if (ImGui::Checkbox("Freecam", &on)) coop::dev::freecam::SetActive(on); + ImGui::TextDisabled("HOME also toggles - WASD move, Space/Ctrl up/down,"); + ImGui::TextDisabled("Shift fast, wheel speed"); + // The middle-click self-teleport moves the pawn in the shared world: the host's alone. + if (coop::dev_gate::Allowed()) ImGui::TextDisabled("MMB bring player"); +} + +void RenderRestoreVitals() { + if (coop::dev_gate::Allowed()) { + if (ImGui::Button("Restore vitals (food / sleep / health)")) coop::dev::restore_vitals::Restore(); + ImGui::SameLine(); + ImGui::TextDisabled("(both peers)"); + } else { + ImGui::TextDisabled("%s", kHostOnlyLine); + } + if (coop::session::local_grants::Has(coop::permissions::grants::Projected::Stamina)) { + if (ImGui::Button("Set stamina low")) coop::dev::restore_vitals::SetStaminaLow(); + ImGui::SameLine(); + ImGui::TextDisabled("(local test -- sleep/energy=10 -> exhausted; nameplate syncs)"); + } else { + ImGui::TextDisabled("%s", kNotAllowedLine); + } +} + +void RenderSetClock() { + if (!coop::dev_gate::Allowed()) { + ImGui::TextDisabled("%s", kHostOnlyLine); + return; + } + namespace SC = coop::dev::set_clock; + int hour = 0, minute = 0, day = 0; + float frac = 0.f; + if (!SC::ReadCurrent(hour, minute, day, frac)) { + ImGui::TextDisabled("World clock not resolved yet (enter a world)."); + return; + } + ImGui::TextDisabled("Host-authoritative; the clock (timeZ -> settime) drives events + the save."); + ImGui::Text("Now: Day %d — %02d:%02d", day, hour, minute); + ImGui::SameLine(); + ImGui::TextDisabled("(sun %.3f)", frac); + static int s_day = -1, s_hour = -1, s_min = -1; + if (s_day < 0) { s_day = day; s_hour = hour; s_min = minute; } // seed once from the live clock + ImGui::SetNextItemWidth(S(90.f)); + ImGui::InputInt("day##clk", &s_day); + ImGui::SameLine(); + ImGui::SetNextItemWidth(S(74.f)); + ImGui::InputInt("h##clk", &s_hour); + ImGui::SameLine(); + ImGui::SetNextItemWidth(S(74.f)); + ImGui::InputInt("m##clk", &s_min); + if (s_day < 1) s_day = 1; + if (s_hour < 0) s_hour = 0; + if (s_hour > 23) s_hour = 23; + if (s_min < 0) s_min = 0; + if (s_min > 59) s_min = 59; + ImGui::SameLine(); + if (ImGui::Button("Set clock")) SC::SetClock(s_day, s_hour, s_min); + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("Writes the game's own running clock (timeZ); the next minute pulse runs\n" + "saveSlot.settime natively. Jumping the day FORWARD fires every skipped\n" + "scheduled story event at once (native settime behavior; they mirror to\n" + "clients via EventFire). Day is the DISPLAYED day (as on the save rows)."); + ImGui::SameLine(); + ImGui::TextDisabled("(host only)"); + float f = frac; + ImGui::SetNextItemWidth(S(260.f)); + if (ImGui::SliderFloat("Sun position", &f, 0.0f, 0.999f, "%.3f")) + SC::SetTimeFraction(f); // live: drag moves the sun (totalTime := frac * MaxTime) + ImGui::TextDisabled("Visual only (sun angle). The clock above is what events/save follow."); +} + +void RenderPosHud() { + if (!coop::session::local_grants::Has(coop::permissions::grants::Projected::Hud)) { + ImGui::TextDisabled("%s", kNotAllowedLine); + return; + } + bool on = coop::dev::pos_hud::IsVisible(); + if (ImGui::Checkbox("Position / camera readout", &on)) coop::dev::pos_hud::SetVisible(on); + ImGui::SameLine(); + ImGui::TextDisabled("(on-screen overlay)"); +} + +void RenderObjectOverlay() { + if (!coop::session::local_grants::Has(coop::permissions::grants::Projected::Overlay)) { + ImGui::TextDisabled("%s", kNotAllowedLine); + return; + } + namespace OO = coop::dev::object_overlay; + bool on = OO::IsEnabled(); + if (ImGui::Checkbox("World object overlay", &on)) OO::SetEnabled(on); + ImGui::SameLine(); + ImGui::TextDisabled("(labels projected onto nearby objects)"); + ImGui::Indent(S(22.f)); + ImGui::BeginDisabled(!on); + bool names = OO::LayerNames(); + if (ImGui::Checkbox("Object names", &names)) OO::SetLayerNames(names); + ImGui::SameLine(); + ImGui::TextDisabled("(class + prop visual row)"); + bool net = OO::LayerNet(); + if (ImGui::Checkbox("Net identity", &net)) OO::SetLayerNet(net); + ImGui::SameLine(); + ImGui::TextDisabled("(eid, mirror/local/untracked, key)"); + bool phys = OO::LayerPhys(); + if (ImGui::Checkbox("Physics state", &phys)) OO::SetLayerPhys(phys); + ImGui::SameLine(); + ImGui::TextDisabled("(sim/rest + static/frozen/sleep)"); + bool hp = OO::LayerHealth(); + if (ImGui::Checkbox("Health / progress", &hp)) OO::SetLayerHealth(hp); + ImGui::SameLine(); + ImGui::TextDisabled("(creature+prop hp; decal clean/cement pools)"); + float r = OO::RadiusM(); + ImGui::SetNextItemWidth(S(170.f)); + if (ImGui::SliderFloat("Radius (m)", &r, 5.f, 100.f, "%.0f")) OO::SetRadiusM(r); + ImGui::EndDisabled(); + ImGui::Unindent(S(22.f)); +} + +void RenderRagdollBones() { + if (!coop::session::local_grants::Has(coop::permissions::grants::Projected::Overlay)) { + ImGui::TextDisabled("%s", kNotAllowedLine); + return; + } + namespace RB = coop::dev::ragdoll_bone_overlay; + bool on = RB::IsEnabled(); + if (ImGui::Checkbox("Ragdoll bone skeleton", &on)) RB::SetEnabled(on); + ImGui::SameLine(); + ImGui::TextDisabled("(lines between every bone of an ACTIVE ragdoll body)"); + ImGui::TextDisabled("The native ragdoll (C key / faint / trip) is an invisible separate actor;"); + ImGui::TextDisabled("orange = your own ragdoll, cyan = a remote peer's mirror body."); +} + +void RenderSpawnNpc() { + if (!coop::dev_gate::Allowed()) { + ImGui::TextDisabled("%s", kHostOnlyLine); + return; + } + if (ImGui::Button("Spawn kerfurOmega (in front)")) coop::dev::spawn_npc::SpawnKerfurOmega(); + ImGui::SameLine(); + ImGui::TextDisabled("(host spawns + syncs)"); + // Spawn the allowlisted creatures directly, in front of the player: the wisp event arms an + // overlap box, and the eventer's ventCrawler lands about ten metres away in a vent (see + // spawn_npc). + if (ImGui::Button("Spawn killerWisp (in front)")) coop::dev::spawn_npc::SpawnKillerWisp(); + ImGui::SameLine(); + if (ImGui::Button("Spawn ventCrawler (in front)")) coop::dev::spawn_npc::SpawnVentCrawler(); + ImGui::SameLine(); + ImGui::TextDisabled("(mirror test: watch the client radar)"); + // Killer-wisp cross-peer kill test: spawn the wisp ON a client puppet so it grabs the + // CLIENT, routing the kill there, instead of the nearest, which is the host. + if (ImGui::Button("Spawn killerWisp ON client (coop kill test)")) + coop::dev::spawn_npc::SpawnKillerWispOnClient(); + ImGui::Separator(); + // Owner-entity lane test: an eyer is per-peer OWNED, its native AI stalking the spawning + // peer, and cross-peer VISIBLE, other peers getting a brain-parked display mirror. + if (ImGui::Button("Spawn Eyer (in front)")) coop::dev::spawn_npc::SpawnEyer(); + ImGui::SameLine(); + ImGui::TextDisabled("(owner-entity test: it stalks YOU; peers see a harmless mirror)"); + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("The night 'Eyes' stalker. Each peer keeps its OWN native eyer\n" + "(it targets the peer that rolled it); every other peer renders a\n" + "display mirror -- AI and killsphere disabled on the mirror."); + if (ImGui::Button("Spawn walking mannequin")) coop::dev::mannequin_drill::SpawnWalker(); + ImGui::SameLine(); + ImGui::TextDisabled("(the game's own creature: use a throwaway save)"); + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("The game's walking mannequin, spawned at its nearest spawn point that is not on screen " + "(it normally appears from day 14).\n" + "It opens doors, is saved with the world and returns angry after a load.\n" + "In a session it crosses to each client as a prop mirror that runs a brain of its own."); +} + +void RenderGivePoints() { + if (!coop::dev_gate::Allowed()) { + ImGui::TextDisabled("%s", kHostOnlyLine); + return; + } + if (ImGui::Button("+1000 Points")) coop::dev::add_points::GivePoints(1000); + ImGui::SameLine(); + ImGui::TextDisabled("(local balance -- afford drone orders)"); +} + +void RenderSpawnMenuUnlock() { + if (!coop::dev_gate::Allowed()) { + ImGui::TextDisabled("%s", kHostOnlyLine); + return; + } + namespace SM = coop::dev::spawn_menu_unlock; + bool on = SM::IsEnabled(); + if (ImGui::Checkbox("Prop spawn menu in story mode (Q)", &on)) SM::SetEnabled(on); + ImGui::SameLine(); + ImGui::TextDisabled("(host/local only)"); + ImGui::Indent(S(22.f)); + ImGui::TextDisabled("Enables the sandbox Q spawn menu in story mode."); + if (ImGui::Button("Open spawn menu now")) SM::OpenNow(); + ImGui::SameLine(); + ImGui::TextDisabled("(or press Q while enabled)"); + ImGui::Unindent(S(22.f)); +} + +// Case-insensitive substring (the filter box; avoids imgui_internal.h). +static bool StrContainsI(const char* hay, const char* needle) { + if (!needle[0]) return true; + for (const char* h = hay; *h; ++h) { + const char* a = h; + const char* b = needle; + while (*a && *b && (tolower(static_cast(*a)) == + tolower(static_cast(*b)))) { ++a; ++b; } + if (!*b) return true; + } + return false; +} + +void RenderEvents() { + if (!coop::dev_gate::Allowed()) { + ImGui::TextDisabled("%s", kHostOnlyLine); + return; + } + namespace ET = coop::dev::event_trigger; + namespace EF = coop::dev::event_force; + EF::RequestRefresh(); // ~1 Hz internal limiter; keeps the ARMED badges live while the tab is open + ImGui::TextDisabled("Trigger any game event (host only; runEvent + runSpecialEvent + ambient verbs)."); + ImGui::TextDisabled("Grouped by strict"); + ImGui::TextDisabled("category. 'day N' = unlock day. The cyan TIME column is the native trigger time-of-"); + ImGui::TextDisabled("day (HH:MM anchor) / 'trigger' (story/build) / 'rep' (ariral-reputation prank pool)."); + ImGui::TextDisabled("Volume-gated events show a badge: fire only ARMS a level volume; NOW! completes it"); + ImGui::TextDisabled("instantly (drives the volume's own overlap with your pawn -- the native walk-in)."); + static char filter[32] = {}; + ImGui::SetNextItemWidth(S(180.f)); + ImGui::InputTextWithHint("##evfilter", "filter (name / category / time / effect)...", filter, sizeof(filter)); + ImGui::Separator(); + // Horizontal scrollbar: the effect column can run past the panel on a narrow window, so the + // reader scrolls right rather than losing the text. The window itself is wider and + // min-constrained too. + ImGui::BeginChild("##evlist", ImVec2(0, 0), ImGuiChildFlags_None, ImGuiWindowFlags_HorizontalScrollbar); + ET::Category lastCat = ET::Category::COUNT; + bool haveCat = false; + for (const auto& ev : ET::Events()) { + const char* catName = ET::CategoryName(ev.cat); + if (filter[0] && !StrContainsI(ev.name, filter) && !StrContainsI(catName, filter) && + !StrContainsI(ev.time, filter) && !StrContainsI(ev.mechanism, filter)) + continue; + if (!haveCat || ev.cat != lastCat) { + lastCat = ev.cat; + haveCat = true; + ImGui::SeparatorText(catName); + } + const bool danger = ev.risk == ET::Risk::Dangerous; + if (danger) + ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.0f, 0.45f, 0.40f, 1.0f)); + else if (ev.risk == ET::Risk::Caution) + ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.0f, 0.85f, 0.45f, 1.0f)); + const bool clicked = ImGui::Button(ev.name, ImVec2(S(150.f), 0)); + if (ev.risk != ET::Risk::Safe) ImGui::PopStyleColor(); + const bool hoveredBtn = ImGui::IsItemHovered(); + ImGui::SameLine(); + if (ev.dayZ >= 0) ImGui::TextDisabled("day %-3d", ev.dayZ); + else ImGui::TextDisabled(" - "); + ImGui::SameLine(); + // The native trigger TIME (hours:minutes) -- the user's key ask. Cyan so it stands out. + ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.55f, 0.85f, 0.95f, 1.0f)); + ImGui::Text("%-7s", ev.time); + ImGui::PopStyleColor(); + // Volume-gated events: the live gate badge + the force-NOW button. + const EF::BoxStatus box = EF::StatusFor(ev.name); + bool forceClicked = false, forceHovered = false; + if (box.hasBox) { + ImGui::SameLine(); + if (!box.resolved) + ImGui::TextDisabled("[gate: volume ?]"); + else if (box.shots == 0) + ImGui::TextColored(ImVec4(0.5f, 0.9f, 0.5f, 1.0f), "[FIRED]"); + else if (box.armed) + ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.3f, 1.0f), "[ARMED - walk-in pending]"); + else + ImGui::TextDisabled("[volume-gated]"); + ImGui::SameLine(); + ImGui::PushID(ev.name); + forceClicked = ImGui::SmallButton("NOW!"); + forceHovered = ImGui::IsItemHovered(); + ImGui::PopID(); + } + ImGui::SameLine(); + ImGui::TextDisabled("%s", ev.mechanism); + const char* note = box.hasBox ? nullptr : EF::GateNote(ev.name); + if (forceHovered) { + ImGui::SetTooltip("Arm + complete NOW: fires the event (clients get the arm as usual), then\n" + "drives %s's own overlap handler with your pawn -- the same dispatch a real\n" + "walk-in performs (native class filter + N bookkeeping).%s", + box.boxName, danger ? "\n\nDangerous event: Ctrl+click." : ""); + } else if (hoveredBtn) { + if (danger) { + ImGui::SetTooltip("%s | native time: %s\n%s%s%s%s\n\nStory/save progression or player relocation" + " --\ncan desync the run. Ctrl+click to trigger.", catName, ev.time, ev.mechanism, + (box.hasBox || note) ? "\ngate: " : "", + box.hasBox ? "fire ARMS the level volume " : (note ? note : ""), + box.hasBox ? box.boxName : ""); + } else { + const char* path = (ev.dispatch == ET::Dispatch::SpecialEvent) ? "runSpecialEvent (specific prank)" + : (ev.dispatch == ET::Dispatch::RandomPrank) ? "runEvent (RANDOM rep-tier prank)" + : (ev.dispatch == ET::Dispatch::Ambient) ? "direct ambient UFunction (daynight/gamemode)" + : "runEvent"; + ImGui::SetTooltip("%s | native time: %s | via %s\n%s%s%s%s", catName, ev.time, path, ev.mechanism, + (box.hasBox || note) ? "\ngate: " : "", + box.hasBox ? "fire ARMS the level volume; walk into it (or NOW!) to complete: " : (note ? note : ""), + box.hasBox ? box.boxName : ""); + } + } + if (clicked && (!danger || ImGui::GetIO().KeyCtrl)) ET::Trigger(ev); + if (forceClicked && (!danger || ImGui::GetIO().KeyCtrl)) EF::ForceNow(ev.name); + } + ImGui::EndChild(); +} + +} // namespace ui::dev_panes From 1d58e58b7fb1695cc35c43e79233971f8bd10144 Mon Sep 17 00:00:00 2001 From: pelmentor Date: Sat, 10 Oct 2026 05:49:38 +0600 Subject: [PATCH 078/306] [comms] a peer action's sentence comes from the feed, its nick a span The feed composed " " from a predicate each caller built, so the nick was always a prefix. A translated sentence may put the actor anywhere, so the feed now owns one msgid per action, the nick as %1$s, and pushes the nick's byte span with the line; the five callers name an action and its argument, and the coin gun's and the store's English reason tables go, their logs printing the feed's msgid. A chat row keeps its prefix. The span is checked as a pair after the 255-byte cut, the HUD draws three runs, and the chat drill's new span arm judges a mid-line nick and a cut one, red with chat_span_prefix. Co-Authored-By: Claude Opus 5.5 --- src/votv-coop/include/coop/comms/chat_feed.h | 24 +-- .../include/coop/comms/peer_action_feed.h | 61 +++++-- .../coop/config/config_registry_rows_dev.inc | 7 +- src/votv-coop/include/coop/dev/chat_drill.h | 8 +- src/votv-coop/include/l10n/mark.h | 2 + src/votv-coop/src/coop/comms/chat_feed.cpp | 35 +++- .../src/coop/comms/peer_action_feed.cpp | 150 +++++++++++++++--- src/votv-coop/src/coop/dev/chat_drill.cpp | 30 +++- .../coop/interactables/device_occupancy.cpp | 4 +- .../coop/interactables/signal_catch_sync.cpp | 8 +- src/votv-coop/src/coop/items/coingun_sync.cpp | 46 +++--- src/votv-coop/src/coop/items/order_sync.cpp | 24 +-- src/votv-coop/src/coop/world/email_sync.cpp | 6 +- src/votv-coop/src/ui/chat_view.cpp | 14 +- 14 files changed, 311 insertions(+), 108 deletions(-) diff --git a/src/votv-coop/include/coop/comms/chat_feed.h b/src/votv-coop/include/coop/comms/chat_feed.h index 169a149b3..0199a9d79 100644 --- a/src/votv-coop/include/coop/comms/chat_feed.h +++ b/src/votv-coop/include/coop/comms/chat_feed.h @@ -72,14 +72,16 @@ enum class Keep : uint8_t { // tick share it. `text` holds up to 255 bytes, and a composed chat line can be longer, so it // is cut at birth on a character boundary. `nickArgb` is the nick prefix's colour, frozen at // birth and resolved once by the receiver when the line is composed (see -// coop/comms/chat_nick_color.h); nickLen marks the prefix's bytes, and 0 is an event line in -// one colour. +// coop/comms/chat_nick_color.h); nickBegin and nickLen mark the nick's bytes -- at the start of a +// chat row, anywhere in a peer-action line, whose translated sentence may put the actor mid-line -- +// and nickLen 0 is an event line in one colour. struct Line { char text[256] = {}; float alpha = 1.f; // STORE alpha (TTL curve); 0 for a retained row uint64_t key = 0; // total order + entry identity (see above) uint32_t nickArgb = 0; // frozen nick colour, 0xAARRGGBB; 0 when nickLen == 0 - uint8_t nickLen = 0; // byte length of the nick prefix inside text + uint8_t nickBegin = 0; // byte offset of the nick inside text: 0 for a chat row's prefix + uint8_t nickLen = 0; // byte length of the nick inside text uint8_t action = 0; // 1 = a peer-action line, whose predicate the HUD draws in the action colour }; @@ -108,8 +110,10 @@ void PushWireChat(const std::string& utf8Line, uint8_t nickByteLen, uint32_t nic uint32_t lineSeq, bool seeded); // Append a peer-action line, the chat shape with the action flag set, so the HUD draws the -// predicate in the action colour. Always History. Game thread. -void PushAction(const std::string& utf8Line, uint8_t nickByteLen, uint32_t nickArgb); +// predicate in the action colour. The nick is the span [nickBegin, nickBegin + nickByteLen) of the +// line, wherever its sentence puts it; a span the 255-byte cut does not keep whole is dropped and the +// line is drawn in one colour. Always History. Game thread. +void PushAction(const std::string& utf8Line, uint8_t nickBegin, uint8_t nickByteLen, uint32_t nickArgb); // Append an event line after `delayMs`, promoted to the live feed by Tick once due; for the // join announces, since a client reports world-ready before its loading screen clears and an @@ -133,14 +137,16 @@ bool GetSnapshotIfNewer(Snapshot& out, uint32_t& gen); // whole overlay frame, alive. bool HasAny(); -// One row of the store as a reader walking it sees it. `nick` is the speaker's name when the row -// has one (nickLen above 0), and `line` is the row's text after the nick and its ": " separator -// (a row with no nick has no prefix: `nick` is empty and `line` is all of it). Both views borrow -// the store's strings and end with the callback; `retained` says which tier the row sits in. +// One row of the store as a reader walking it sees it. `nick` is the speaker's or actor's name when +// the row has one (nickLen above 0), at byte `nickBegin` of the text. `line` is the row's text after +// the nick and its ": " separator when the nick begins the row, and the whole text when it begins +// later (a row with no nick: `nick` is empty and `line` is all of it). Both views borrow the store's +// strings and end with the callback; `retained` says which tier the row sits in. struct RowView { unsigned long long key; std::string_view nick; std::string_view line; + unsigned nickBegin; bool retained; }; diff --git a/src/votv-coop/include/coop/comms/peer_action_feed.h b/src/votv-coop/include/coop/comms/peer_action_feed.h index 9e7437865..ef2fe5169 100644 --- a/src/votv-coop/include/coop/comms/peer_action_feed.h +++ b/src/votv-coop/include/coop/comms/peer_action_feed.h @@ -8,10 +8,9 @@ // ui.chat.peer_actions toggle (F1 > Cosmetics > Chat), default on -- a local view preference, // not a wire broadcast. // -// Renders " " with the nick coloured per slot (chat_feed::PushChat). The subject -// is ALWAYS the actor's nickname, so the local actor sees the same line everyone else sees. -// Announce() and AnnounceDirect() are GAME THREAD, as every caller is; Enabled() is lock-free. -// The toggle follows its config row through SubscribeRow()'s subscriber (game thread). +// The subject is ALWAYS the actor's nickname, so the local actor sees the same line everyone else +// sees. Announce*() are GAME THREAD, as every caller is; Enabled() is lock-free. The toggle follows +// its config row through SubscribeRow()'s subscriber (game thread). #pragma once #include @@ -19,17 +18,49 @@ namespace coop::peer_action_feed { -// Announce that a peer performed `action` (a predicate like -// L"deleted an email: Server Alert!"). `slot` is the actor's peer slot (drives the -// nick + its color); the local slot resolves to LocalNickname(), any other to -// NicknameForSlot(). No-op unless Enabled(). Game thread. -void Announce(uint8_t slot, const std::wstring& action); - -// Same rendering, NOT gated on the ui.chat.peer_actions toggle. The one grammar -// owner for peer-attributed lines that are FUNCTIONAL feedback rather than -// cosmetic ambience (device_occupancy's busy-deny notice: suppressing it would -// reduce the deny to a bare click sound). Game thread. -void AnnounceDirect(uint8_t slot, const std::wstring& action); +// The one grammar owner for peer-attributed lines: a caller names an Action and its argument, and +// the feed holds the sentence -- one msgid per action, the actor's nick as %1$s wherever the +// translation puts it -- looks it up in the player's language (l10n), formats it and colours the +// nick's span (chat_feed::PushAction). A caller never composes text and never imports l10n; Source +// sends a token and its parameters for each client to resolve (UTIL_ClientPrintAll), and this is that +// shape inside one peer. An action whose sentence takes a name takes it as `arg`; SoldFor takes a +// count. The sentences are the table in peer_action_feed.cpp. +enum class Action : uint8_t { + DeletedEmail, // arg: the email's subject + CaughtSignal, // arg: the signal's name + UsingDevice, // arg: the device's name + SoldFor, // a count: the price the host minted + SellNoSuchProp, + SellAlreadySold, + SellNoGun, + SellNotSellable, + SellHostInternal, + SellTooFarAway, + SellRefused, + OrderTooMany, + OrderUnknownItem, + OrderUnaffordable, + OrderNoCatalog, + OrderCommitFailed, + OrderRefused, + kCount +}; + +// Announce that a peer performed `action`. `slot` is the actor's peer slot (drives the nick and its +// colour); the local slot resolves to LocalNickname(), any other to NicknameForSlot(). An action +// that takes no name ignores `arg`. No-op unless Enabled(). Game thread. +void Announce(uint8_t slot, Action action, const std::wstring& arg = {}); + +// Same rendering, NOT gated on the ui.chat.peer_actions toggle: for lines that are FUNCTIONAL +// feedback rather than cosmetic ambience (device_occupancy's busy-deny notice: suppressing it would +// reduce the deny to a bare click sound; a refused sale or order). Game thread. +void AnnounceDirect(uint8_t slot, Action action, const std::wstring& arg = {}); + +// AnnounceDirect for an action that takes a count (SoldFor): the plural form follows `n`. +void AnnounceDirectCount(uint8_t slot, Action action, long long n); + +// The action's English sentence, for a log line beside the feed's. +const char* English(Action action); // The ui.chat.peer_actions toggle: Enabled reads the live value (lazy-loads the row's // resolved value on first call). A change is a config SetValue; the subscriber applies it. diff --git a/src/votv-coop/include/coop/config/config_registry_rows_dev.inc b/src/votv-coop/include/coop/config/config_registry_rows_dev.inc index d39b18387..ddddf33fb 100644 --- a/src/votv-coop/include/coop/config/config_registry_rows_dev.inc +++ b/src/votv-coop/include/coop/config/config_registry_rows_dev.inc @@ -189,9 +189,12 @@ CFG_ENUM(grants_drill, "grants_drill", "dev", "off", "off|grant|none|hostdeny", "The local grants drill: checks this machine's dev grants against an arm's expectation.") CFG_ENUM(grants_drill_expect, "grants_drill_expect", "dev", "same", "same|grant|none|hostdeny", "VOTVCOOP_GRANTS_DRILL_EXPECT", "Judges the grants drill against another arm's expectation (a red arm).") -CFG_ENUM(chat_drill, "chat_drill", "dev", "off", "off|history|seed|i18n", "VOTVCOOP_CHAT_DRILL", +CFG_ENUM(chat_drill, "chat_drill", "dev", "off", "off|history|seed|i18n|span", "VOTVCOOP_CHAT_DRILL", "Developer: chat checks judged in memory -- history (the host), seed (the second joiner), i18n " - "(every peer); each prints [CHAT-DRILL] PASS, FAIL or ABORT.") + "(every peer), span (every peer: a mid-line nick's span through the feed); each prints " + "[CHAT-DRILL] PASS, FAIL or ABORT.") +CFG_FLAG(chat_span_prefix, "chat_span_prefix", "dev", false, "VOTVCOOP_CHAT_SPAN_PREFIX", + "Developer: keep a peer action's nick as a prefix wherever its sentence put it (the chat drill's span red).") CFG_FLAG(chat_drill_bad_nick, "chat_drill_bad_nick", "dev", false, "VOTVCOOP_CHAT_DRILL_BAD_NICK", "Developer: the chat drill announces a wrong nickname (the i18n red).") CFG_FLAG(chat_no_retain, "chat_no_retain", "dev", false, "VOTVCOOP_CHAT_NO_RETAIN", diff --git a/src/votv-coop/include/coop/dev/chat_drill.h b/src/votv-coop/include/coop/dev/chat_drill.h index dcc3f1cf2..71523f3b3 100644 --- a/src/votv-coop/include/coop/dev/chat_drill.h +++ b/src/votv-coop/include/coop/dev/chat_drill.h @@ -1,5 +1,5 @@ // coop/dev/chat_drill.h -- drill: the chat's checks, judged in memory (ini chat_drill= -// off|history|seed|i18n / env VOTVCOOP_CHAT_DRILL; every peer in the arm). +// off|history|seed|i18n|span / env VOTVCOOP_CHAT_DRILL; every peer in the arm). // // Sends through the chat's own submit call and reads the feed's rows (chat_feed::ForEachRow); no // keystroke and no log text is judged. Every line begins "[chat-drill] " (host, c1, c2, c3), @@ -7,10 +7,12 @@ // judges the retained tier and the open history. seed: host + c1, then c2, which JUDGES the // joiner's seed and the lines said while it loaded. i18n: all four, EVERY peer judges the others' // lines, in four scripts, and nicknames; its wait restarts its budget at each new line and its -// ABORT names the lines still missing. Lines tagged [CHAT-DRILL]: " PASS", " FAIL: +// ABORT names the lines still missing. span: every peer pushes a peer-action line with its nick +// mid-line and one whose nick the 255-byte cut reaches, and judges what the feed hands back. Lines tagged [CHAT-DRILL]: " PASS", " FAIL: // " (roles, indices, counts; never chat text), " ABORT: " (not a measurement: a // wait ran out or the case could not be exercised), and the seed arm's rig cue "host HAS c1 4". -// Reds: chat_no_retain (history), chat_seed_suppress (seed), chat_drill_bad_nick (i18n, on c1). +// Reds: chat_no_retain (history), chat_seed_suppress (seed), chat_drill_bad_nick (i18n, on c1), +// chat_span_prefix (span). #pragma once diff --git a/src/votv-coop/include/l10n/mark.h b/src/votv-coop/include/l10n/mark.h index 66c4534a0..1bd9d6282 100644 --- a/src/votv-coop/include/l10n/mark.h +++ b/src/votv-coop/include/l10n/mark.h @@ -6,3 +6,5 @@ #define L10N_MARK(s) s #define L10N_MARK_C(ctx, s) s +// A plural pair in a table row: expands to `singular, plural`, two initialisers, for Tn at the drawer. +#define L10N_MARK_N(singular, plural) singular, plural diff --git a/src/votv-coop/src/coop/comms/chat_feed.cpp b/src/votv-coop/src/coop/comms/chat_feed.cpp index c2aa10eef..aee251401 100644 --- a/src/votv-coop/src/coop/comms/chat_feed.cpp +++ b/src/votv-coop/src/coop/comms/chat_feed.cpp @@ -32,6 +32,7 @@ struct Entry { uint64_t bornMs = 0; // wall birth (fade clock base) -- NOT identity uint64_t bornSuspendedMs = 0; // the suspension accumulator AT BIRTH (see below) uint32_t nickArgb = 0; + uint8_t nickBegin = 0; uint8_t nickLen = 0; uint8_t action = 0; Keep keep = Keep::Transient; @@ -139,6 +140,13 @@ bool NoRetain() { return v; } +// The span arm's must-fail control: with this set, a peer action's nick is kept as a prefix of its +// length, as the feed held it before a translated sentence could move it, so the drill must go red. +bool SpanAsPrefix() { + static const bool v = coop::config::ResolveFlag(::coop::config_registry::rows::chat_span_prefix); + return v; +} + // The resurrection probe: a long-gone line was seen reappearing for half a second and fading // out again. Static analysis says the store cannot do it (a per-entry alpha rises only during // the arrival ramp and is monotone-decreasing after), so the mechanism is either a duplicate @@ -169,9 +177,9 @@ void NoteDestroyed(const std::string& text, uint64_t key, uint64_t now) { } void ProbeOnPush(const char* via, const Entry& e, size_t linesNow) { - UE_LOGI("feed: push via=%s keep=%s nickLen=%u lines=%zu key=%llu textBytes=%zu", + UE_LOGI("feed: push via=%s keep=%s nickBegin=%u nickLen=%u lines=%zu key=%llu textBytes=%zu", via, e.keep == Keep::History ? "history" : "transient", - static_cast(e.nickLen), linesNow, + static_cast(e.nickBegin), static_cast(e.nickLen), linesNow, static_cast(e.key), e.text.size()); for (const Expired& x : g_expired) { if (!x.text[0] || x.atMs == 0) continue; @@ -322,6 +330,7 @@ void FillLine(Line& l, const Entry& e, float alpha) { l.alpha = alpha; l.key = e.key; l.nickArgb = e.nickArgb; + l.nickBegin = e.nickBegin; l.nickLen = e.nickLen; l.action = e.action; } @@ -431,7 +440,7 @@ void PushWireChat(const std::string& utf8Line, uint8_t nickByteLen, uint32_t nic Republish(now); } -void PushAction(const std::string& utf8Line, uint8_t nickByteLen, uint32_t nickArgb) { +void PushAction(const std::string& utf8Line, uint8_t nickBegin, uint8_t nickByteLen, uint32_t nickArgb) { const uint64_t now = NowMs(); AdvanceSuspension(now); Entry e; @@ -439,7 +448,12 @@ void PushAction(const std::string& utf8Line, uint8_t nickByteLen, uint32_t nickA e.key = NextKey(); e.bornMs = now; e.bornSuspendedMs = g_suspendedMs; - e.nickLen = (nickByteLen <= e.text.size()) ? nickByteLen : 0; + // The span is checked as a pair against the text the cut kept: a nick the cut reached is no + // span at all, never a coloured tail of something else. + if (static_cast(nickBegin) + nickByteLen <= e.text.size()) { + e.nickBegin = SpanAsPrefix() ? 0 : nickBegin; + e.nickLen = nickByteLen; + } e.nickArgb = nickArgb; e.action = 1; e.keep = Keep::History; @@ -507,10 +521,15 @@ void ForEachRow(const std::function& fn) { auto walk = [&fn](const std::deque& tier, bool retained) { for (const Entry& e : tier) { const std::string_view text = e.text; - size_t cut = e.nickLen; // the pushers keep nickLen <= text.size() - const std::string_view nick = text.substr(0, cut); - if (cut != 0 && text.compare(cut, 2, ": ") == 0) cut += 2; - fn(RowView{e.key, nick, text.substr(cut), retained}); + // The pushers keep nickBegin + nickLen <= text.size(). + const std::string_view nick = text.substr(e.nickBegin, e.nickLen); + std::string_view line = text; + if (e.nickBegin == 0) { + size_t cut = e.nickLen; + if (cut != 0 && text.compare(cut, 2, ": ") == 0) cut += 2; + line = text.substr(cut); + } + fn(RowView{e.key, nick, line, e.nickBegin, retained}); } }; walk(g_store.live(), false); diff --git a/src/votv-coop/src/coop/comms/peer_action_feed.cpp b/src/votv-coop/src/coop/comms/peer_action_feed.cpp index e548cc303..8439db1b3 100644 --- a/src/votv-coop/src/coop/comms/peer_action_feed.cpp +++ b/src/votv-coop/src/coop/comms/peer_action_feed.cpp @@ -9,8 +9,12 @@ #include "coop/config/config.h" +#include "l10n/l10n.h" +#include "ue_wrap/core/log.h" + #include #include +#include namespace coop::peer_action_feed { namespace { @@ -35,42 +39,146 @@ void OnPeerActionsRowChanged() { std::memory_order_relaxed); } -} // namespace +// The sentences, one per Action and in its order: the actor is %1$s, a name %2$s, a count %2$lld. +// What argument each takes is the row's kind. +enum class Kind : uint8_t { None, Name, Count }; +struct Row { + Action action; + const char* msgid; + const char* plural; // a count's plural msgid, or null + Kind kind; +}; +constexpr Row kRows[] = { + {Action::DeletedEmail, L10N_MARK("%1$s deleted an email: %2$s"), nullptr, Kind::Name}, + {Action::CaughtSignal, L10N_MARK("%1$s caught signal '%2$s'"), nullptr, Kind::Name}, + {Action::UsingDevice, L10N_MARK("%1$s is using %2$s"), nullptr, Kind::Name}, + {Action::SoldFor, L10N_MARK_N("%1$s sold it for %2$lld point (the host's price)", + "%1$s sold it for %2$lld points (the host's price)"), Kind::Count}, + {Action::SellNoSuchProp, L10N_MARK("%1$s could not sell that: the host does not have it"), nullptr, Kind::None}, + {Action::SellAlreadySold, L10N_MARK("%1$s could not sell that: it was already sold"), nullptr, Kind::None}, + {Action::SellNoGun, L10N_MARK("%1$s could not sell that: no coin gun exists in the host's world"), nullptr, + Kind::None}, + {Action::SellNotSellable, L10N_MARK("%1$s could not sell that: the host's store will not take it"), nullptr, + Kind::None}, + {Action::SellHostInternal, L10N_MARK("%1$s could not sell that: the host hit an internal error"), nullptr, + Kind::None}, + {Action::SellTooFarAway, L10N_MARK("%1$s could not sell that: the host does not see you next to it"), nullptr, + Kind::None}, + {Action::SellRefused, L10N_MARK("%1$s could not sell that"), nullptr, Kind::None}, + {Action::OrderTooMany, L10N_MARK("%1$s could not order: too many items in one order"), nullptr, Kind::None}, + {Action::OrderUnknownItem, L10N_MARK("%1$s could not order: an item was not in the host's store"), nullptr, + Kind::None}, + {Action::OrderUnaffordable, L10N_MARK("%1$s could not order: there were not enough credits"), nullptr, + Kind::None}, + {Action::OrderNoCatalog, L10N_MARK("%1$s could not order: the host could not read its store"), nullptr, + Kind::None}, + {Action::OrderCommitFailed, L10N_MARK("%1$s could not order: the delivery could not be placed"), nullptr, + Kind::None}, + {Action::OrderRefused, L10N_MARK("%1$s could not order: the host refused it"), nullptr, Kind::None}, +}; +static_assert(sizeof(kRows) / sizeof(kRows[0]) == static_cast(Action::kCount), + "one sentence per Action"); -void SubscribeRow() { - coop::config::Subscribe(::coop::config_registry::rows::ui_chat_peer_actions, - &OnPeerActionsRowChanged); +constexpr bool RowsInOrder() { + for (size_t i = 0; i < sizeof(kRows) / sizeof(kRows[0]); ++i) + if (static_cast(kRows[i].action) != i) return false; + return true; } +static_assert(RowsInOrder(), "kRows is indexed by Action"); -bool Enabled() { - EnsureLoaded(); - return g_enabled.load(std::memory_order_relaxed); +const Row* RowOf(Action a) { + const size_t i = static_cast(a); + return i < static_cast(Action::kCount) ? &kRows[i] : nullptr; } -void AnnounceDirect(uint8_t slot, const std::wstring& action) { - // The subject is ALWAYS a nickname (the Minecraft feed principle): the local actor's own line - // renders its own nick, same as every other peer sees it. Roster's resolution pattern - // (coop/player/roster.cpp). +// The actor is ALWAYS a nickname (the Minecraft feed principle): the local actor's own line renders +// its own nick, as every other peer sees it. Roster's resolution pattern (coop/player/roster.cpp). +std::string NickOf(uint8_t slot) { const bool isLocal = (slot == coop::players::Registry::Get().LocalPeerId()); const std::wstring& nickW = isLocal ? coop::player_handshake::LocalNickname() : coop::player_handshake::NicknameForSlot(static_cast(slot)); - const std::string nick = coop::chat_feed::ToUtf8(nickW.empty() ? std::wstring(L"Player") : nickW); - const std::string line = nick + " " + coop::chat_feed::ToUtf8(action); + return coop::chat_feed::ToUtf8(nickW.empty() ? std::wstring(L"Player") : nickW); +} - // PushAction colors the first nickLen BYTES per `slot` (chat parity); the rest is the action - // predicate in the ACTION color (yellow), so a world-state action reads apart from typed chat. - // Out-of-range slot (a local actor before slot assignment) clamps to 0 for the color lookup - // only. +// The sentence in the player's language, formatted with the nick as argument 1, and the nick's span +// coloured per `slot`; the rest is the predicate in the ACTION colour (yellow), so a world-state +// action reads apart from typed chat. +void Say(uint8_t slot, const Row& row, const std::wstring& arg, long long n) { + const std::string nick = NickOf(slot); + char line[256]; + l10n::Span span; + int written = -1; + switch (row.kind) { + case Kind::None: + written = l10n::FmtSpan(line, sizeof(line), &span, l10n::T(row.msgid), nick.c_str()); + break; + case Kind::Name: { + const std::string name = coop::chat_feed::ToUtf8(arg); + written = l10n::FmtSpan(line, sizeof(line), &span, l10n::T(row.msgid), nick.c_str(), name.c_str()); + break; + } + case Kind::Count: { + const unsigned long long magnitude = + n < 0 ? 0ull - static_cast(n) : static_cast(n); + written = l10n::FmtSpan(line, sizeof(line), &span, l10n::Tn(row.msgid, row.plural, magnitude), nick.c_str(), n); + break; + } + } + if (written < 0) return; // a format the grammar refuses: Fmt said so, once + // An out-of-range slot (a local actor before slot assignment) clamps to 0 for the colour only. const uint8_t colorSlot = slot < coop::players::kMaxPeers ? slot : 0; - coop::chat_feed::PushAction(line, - static_cast(nick.size() > 255 ? 255 : nick.size()), + const bool hasSpan = span.begin >= 0 && span.len > 0; + coop::chat_feed::PushAction(line, static_cast(hasSpan ? span.begin : 0), + static_cast(hasSpan ? span.len : 0), coop::chat_nick_color::ForSlot(colorSlot)); } -void Announce(uint8_t slot, const std::wstring& action) { +// A caller that names an action with the wrong argument shape is a defect at that call site. +bool Fits(const Row* row, Kind want) { + if (row && row->kind == want) return true; + static std::atomic s_said{false}; + if (!s_said.exchange(true)) + UE_LOGE("peer_action_feed: action %d announced with the wrong argument -- not shown", + row ? static_cast(row->action) : -1); + return false; +} + +} // namespace + +void SubscribeRow() { + coop::config::Subscribe(::coop::config_registry::rows::ui_chat_peer_actions, + &OnPeerActionsRowChanged); +} + +bool Enabled() { + EnsureLoaded(); + return g_enabled.load(std::memory_order_relaxed); +} + +void AnnounceDirect(uint8_t slot, Action action, const std::wstring& arg) { + const Row* row = RowOf(action); + if (!row || row->kind == Kind::Count) { + Fits(row, Kind::None); + return; + } + Say(slot, *row, arg, 0); +} + +void Announce(uint8_t slot, Action action, const std::wstring& arg) { if (!Enabled()) return; - AnnounceDirect(slot, action); + AnnounceDirect(slot, action, arg); +} + +void AnnounceDirectCount(uint8_t slot, Action action, long long n) { + const Row* row = RowOf(action); + if (!Fits(row, Kind::Count)) return; + Say(slot, *row, std::wstring(), n); +} + +const char* English(Action action) { + const Row* row = RowOf(action); + return row ? row->msgid : "?"; } } // namespace coop::peer_action_feed diff --git a/src/votv-coop/src/coop/dev/chat_drill.cpp b/src/votv-coop/src/coop/dev/chat_drill.cpp index 1556f747f..b68d6a85e 100644 --- a/src/votv-coop/src/coop/dev/chat_drill.cpp +++ b/src/votv-coop/src/coop/dev/chat_drill.cpp @@ -30,12 +30,13 @@ namespace { namespace CF = coop::chat_feed; -enum class Arm : uint8_t { Off, History, Seed, I18n }; +enum class Arm : uint8_t { Off, History, Seed, I18n, Span }; Arm ArmNow() { static const Arm arm = [] { const std::string v = coop::config::ResolveEnum(::coop::config_registry::rows::chat_drill); - return v == "history" ? Arm::History : v == "seed" ? Arm::Seed : v == "i18n" ? Arm::I18n : Arm::Off; + return v == "history" ? Arm::History : v == "seed" ? Arm::Seed : v == "i18n" ? Arm::I18n + : v == "span" ? Arm::Span : Arm::Off; }(); return arm; } @@ -317,6 +318,28 @@ void JudgeSeed() { } // The start of every arm, once this peer is ready. False while it is not. +// The span arm, on every peer and in memory: a peer-action line whose nick a translated sentence put +// mid-line keeps that span through the feed and reads back as it was pushed; a span the 255-byte cut +// reaches is dropped, never kept as a coloured tail; a chat row's prefix reads as before. It tests the +// feed, not a catalogue: the lines are pushed directly, as the peer-action feed pushes its own. +void JudgeSpan() { + const std::string mid = "[chat-drill] span: before Nick after"; + const std::string cut = "[chat-drill] spancut " + std::string(240, 'x') + " Nick"; + const auto at = static_cast(mid.find("Nick")); + CF::PushAction(mid, at, 4, 0xFFFFFFFFu); + CF::PushAction(cut, static_cast(cut.size() - 4), 4, 0xFFFFFFFFu); + bool midOk = false, cutOk = false; + CF::ForEachRow([&](const CF::RowView& r) { + if (r.line.starts_with("[chat-drill] span: ")) + midOk = r.nick == "Nick" && r.nickBegin == at && r.line == mid; + else if (r.line.starts_with("[chat-drill] spancut ")) + cutOk = r.nick.empty() && r.nickBegin == 0; + }); + if (!midOk) Fail("a mid-line nick did not read back as its span"); + else if (!cutOk) Fail("a nick past the cut kept a span"); + else Pass(); +} + bool Begin(coop::net::Session* session) { g_isHost = session->role() == coop::net::Role::Host; if (!g_isHost && !ClientReady(session)) return false; @@ -357,6 +380,9 @@ bool Begin(coop::net::Session* session) { Enter(Phase::IWaitLines, kJoinWaitS, "the other roles' lines"); break; } + case Arm::Span: + JudgeSpan(); + break; case Arm::Off: g_phase = Phase::Done; break; diff --git a/src/votv-coop/src/coop/interactables/device_occupancy.cpp b/src/votv-coop/src/coop/interactables/device_occupancy.cpp index 17851bb79..ad298f273 100644 --- a/src/votv-coop/src/coop/interactables/device_occupancy.cpp +++ b/src/votv-coop/src/coop/interactables/device_occupancy.cpp @@ -90,8 +90,8 @@ void PushBusyLine(const std::wstring& key, const std::wstring& deviceName, uint8 if (key == g_lastLineKey && now - g_lastLineTime < kLineCooldown) return; g_lastLineKey = key; g_lastLineTime = now; - coop::peer_action_feed::AnnounceDirect( - holder, L"is using " + (deviceName.empty() ? key : deviceName)); + coop::peer_action_feed::AnnounceDirect(holder, coop::peer_action_feed::Action::UsingDevice, + deviceName.empty() ? key : deviceName); } bool g_observersInstalled = false; diff --git a/src/votv-coop/src/coop/interactables/signal_catch_sync.cpp b/src/votv-coop/src/coop/interactables/signal_catch_sync.cpp index da97bf2c2..10558e6e2 100644 --- a/src/votv-coop/src/coop/interactables/signal_catch_sync.cpp +++ b/src/votv-coop/src/coop/interactables/signal_catch_sync.cpp @@ -292,7 +292,8 @@ void RunDetectors(coop::net::Session* s, const CD::CoordSignal& sig, bool haveSi UE_LOGI("signal_catch: the host's verdict for slot %u caught '%ls' -- relayed as slot %u's", static_cast(author), sig.objectName.c_str(), static_cast(author)); - coop::peer_action_feed::Announce(author, L"caught signal '" + sig.objectName + L"'"); + coop::peer_action_feed::Announce(author, coop::peer_action_feed::Action::CaughtSignal, + sig.objectName); } else { SendAs(s, p, /*author*/ 0); // the host's own catch, to every client ++g_localCatches; @@ -302,7 +303,7 @@ void RunDetectors(coop::net::Session* s, const CD::CoordSignal& sig, bool haveSi // receivers announce at their receive sites. coop::peer_action_feed::Announce( coop::players::Registry::Get().LocalPeerId(), - L"caught signal '" + sig.objectName + L"'"); + coop::peer_action_feed::Action::CaughtSignal, sig.objectName); } } } @@ -371,7 +372,8 @@ void OnReliable(const coop::net::SkySignalCatchPayload& p, uint8_t senderSlot) { ApplyReplay(p); // kind 2, a connect seed or a catch whose pinger left, is announced by nobody: no feed line. if (p.kind == 0) - coop::peer_action_feed::Announce(senderSlot, L"caught signal '" + WireName(p.row) + L"'"); + coop::peer_action_feed::Announce(senderSlot, coop::peer_action_feed::Action::CaughtSignal, + WireName(p.row)); } void QueueConnectBroadcastForSlot(int peerSlot) { diff --git a/src/votv-coop/src/coop/items/coingun_sync.cpp b/src/votv-coop/src/coop/items/coingun_sync.cpp index 7bc968861..bbf723c9a 100644 --- a/src/votv-coop/src/coop/items/coingun_sync.cpp +++ b/src/votv-coop/src/coop/items/coingun_sync.cpp @@ -192,23 +192,19 @@ sg::Verdict OnVerbEntry(const sg::Call& b) { // Host helpers. -const wchar_t* ResultText(coop::net::CoinGunResultCode code) { +// The feed's line for a refused sale; its sentences live in the feed (coop/comms/peer_action_feed). +coop::peer_action_feed::Action RefusalAction(coop::net::CoinGunResultCode code) { + using A = coop::peer_action_feed::Action; switch (code) { - case coop::net::CoinGunResultCode::Sold: return L"sold it"; - case coop::net::CoinGunResultCode::NoSuchProp: - return L"could not sell that: the host does not have it"; - case coop::net::CoinGunResultCode::AlreadySold: - return L"could not sell that: it was already sold"; - case coop::net::CoinGunResultCode::NoGun: - return L"could not sell that: no coin gun exists in the host's world"; - case coop::net::CoinGunResultCode::NotSellable: - return L"could not sell that: the host's store will not take it"; - case coop::net::CoinGunResultCode::HostInternal: - return L"could not sell that: the host hit an internal error"; - case coop::net::CoinGunResultCode::TooFarAway: - return L"could not sell that: the host does not see you next to it"; + case coop::net::CoinGunResultCode::NoSuchProp: return A::SellNoSuchProp; + case coop::net::CoinGunResultCode::AlreadySold: return A::SellAlreadySold; + case coop::net::CoinGunResultCode::NoGun: return A::SellNoGun; + case coop::net::CoinGunResultCode::NotSellable: return A::SellNotSellable; + case coop::net::CoinGunResultCode::HostInternal: return A::SellHostInternal; + case coop::net::CoinGunResultCode::TooFarAway: return A::SellTooFarAway; + case coop::net::CoinGunResultCode::Sold: break; } - return L"could not sell that"; + return A::SellRefused; } } // namespace @@ -478,23 +474,23 @@ void OnReliableResult(const uint8_t* payload, int len) { std::memcpy(&r, payload, sizeof(r)); const auto code = static_cast(r.code); - std::wstring line; + // AnnounceDirect, not Announce: functional feedback about the player's own action, which the + // peer-actions toggle must not be able to hide. + const auto local = static_cast(coop::players::Registry::Get().LocalPeerId()); if (code == coop::net::CoinGunResultCode::Sold) { // The price, not a bare acknowledgement: the price multiplier is per instance and can // diverge (a battery by its charge, food by its uses), so the toast the player's own local // sell printed can name a different number than the host minted, and saying the host's // makes that visible. - line = L"sold it for " + std::to_wstring(static_cast(r.points)) + - L" points (the host's price)"; + coop::peer_action_feed::AnnounceDirectCount(local, coop::peer_action_feed::Action::SoldFor, r.points); + UE_LOGI("coingun[client]: CoinGunResult code=%u points=%d -- sold", static_cast(r.code), + r.points); } else { - line = ResultText(code); + const auto action = RefusalAction(code); + coop::peer_action_feed::AnnounceDirect(local, action); + UE_LOGI("coingun[client]: CoinGunResult code=%u points=%d -- '%s'", static_cast(r.code), + r.points, coop::peer_action_feed::English(action)); } - // AnnounceDirect, not Announce: functional feedback about the player's own action, which the - // peer-actions toggle must not be able to hide. - coop::peer_action_feed::AnnounceDirect( - static_cast(coop::players::Registry::Get().LocalPeerId()), line); - UE_LOGI("coingun[client]: CoinGunResult code=%u points=%d -- '%ls'", - static_cast(r.code), r.points, line.c_str()); } void OnDisconnect() { diff --git a/src/votv-coop/src/coop/items/order_sync.cpp b/src/votv-coop/src/coop/items/order_sync.cpp index eb8f3e114..a079c9867 100644 --- a/src/votv-coop/src/coop/items/order_sync.cpp +++ b/src/votv-coop/src/coop/items/order_sync.cpp @@ -131,7 +131,7 @@ void ForwardOrder(net::Session* s, const OE::OrderData& od) { total, net::kMaxOrderItems); coop::peer_action_feed::AnnounceDirect( static_cast(coop::players::Registry::Get().LocalPeerId()), - L"could not order: too many items in one order"); + coop::peer_action_feed::Action::OrderTooMany); return; } const uint32_t orderId = ++g_orderIdCounter; @@ -360,14 +360,16 @@ void TickHost(net::Session* s) { } // ---- CLIENT: a refusal came back ---- -const wchar_t* ReasonText(uint8_t reason) { +// The feed's line for a refused order; its sentences live in the feed (coop/comms/peer_action_feed). +coop::peer_action_feed::Action RefusalAction(uint8_t reason) { + using A = coop::peer_action_feed::Action; switch (static_cast(reason)) { - case net::OrderRefusedReason::UnknownItem: return L"an item was not in the host's store"; - case net::OrderRefusedReason::Unaffordable: return L"there were not enough credits"; - case net::OrderRefusedReason::NoCatalog: return L"the host could not read its store"; - case net::OrderRefusedReason::CommitFailed: return L"the delivery could not be placed"; + case net::OrderRefusedReason::UnknownItem: return A::OrderUnknownItem; + case net::OrderRefusedReason::Unaffordable: return A::OrderUnaffordable; + case net::OrderRefusedReason::NoCatalog: return A::OrderNoCatalog; + case net::OrderRefusedReason::CommitFailed: return A::OrderCommitFailed; } - return L"the host refused it"; + return A::OrderRefused; } void OnRefused(const void* payload, int len) { @@ -388,12 +390,12 @@ void OnRefused(const void* payload, int len) { // Say it, then put the cart back. The base game's own affordability gate pops BEFORE it clears // the cart, so a refused purchase leaves the cart intact; our refusal arrives after the local // run already cleared it, and not restoring would invent a punishment single-player lacks. - const std::wstring line = std::wstring(L"could not order: ") + ReasonText(p.reason); + const auto action = RefusalAction(p.reason); coop::peer_action_feed::AnnounceDirect( - static_cast(coop::players::Registry::Get().LocalPeerId()), line); + static_cast(coop::players::Registry::Get().LocalPeerId()), action); const int32_t restored = OE::RestoreCartItems(rows); - UE_LOGW("order_sync: order id=%u REFUSED by the host (%ls); %d of %zu item(s) restored to the " - "cart", p.orderId, ReasonText(p.reason), restored, rows.size()); + UE_LOGW("order_sync: order id=%u REFUSED by the host ('%s'); %d of %zu item(s) restored to the " + "cart", p.orderId, coop::peer_action_feed::English(action), restored, rows.size()); } } // namespace diff --git a/src/votv-coop/src/coop/world/email_sync.cpp b/src/votv-coop/src/coop/world/email_sync.cpp index e17db1a53..a22853b27 100644 --- a/src/votv-coop/src/coop/world/email_sync.cpp +++ b/src/votv-coop/src/coop/world/email_sync.cpp @@ -525,8 +525,8 @@ void Tick() { // in a session. if (s->connected()) for (const std::wstring& topic : removedTopics) - coop::peer_action_feed::Announce(localSlot, - L"deleted an email: " + topic); + coop::peer_action_feed::Announce(localSlot, coop::peer_action_feed::Action::DeletedEmail, + topic); // A bulk batch of new rows in one poll is a save load, adopted as baseline and never // broadcast. It catches a prime that ran before the real save loaded (the count sat stable // at a menu or partial value), which the stabilise guard alone can miss. @@ -636,7 +636,7 @@ void OnDelete(const coop::net::ContentHashPayload& p, uint8_t senderSlot) { std::wstring topic; if (ApplyDeleteByHash(p.contentHash, &topic)) { // A remote peer deleted this shared email: surface who, to the local feed. - coop::peer_action_feed::Announce(senderSlot, L"deleted an email: " + topic); + coop::peer_action_feed::Announce(senderSlot, coop::peer_action_feed::Action::DeletedEmail, topic); } else { // Deferred (row not present yet, a transient misalignment): the tombstone retry in Tick // applies it later without an announce, since the sender slot is not carried on the retry diff --git a/src/votv-coop/src/ui/chat_view.cpp b/src/votv-coop/src/ui/chat_view.cpp index d897675d6..e74c4b6a9 100644 --- a/src/votv-coop/src/ui/chat_view.cpp +++ b/src/votv-coop/src/ui/chat_view.cpp @@ -327,15 +327,21 @@ void Draw() { nickCol = IM_COL32((l.nickArgb >> 16) & 0xFFu, (l.nickArgb >> 8) & 0xFFu, l.nickArgb & 0xFFu, static_cast(a * 255.f)); } + // Three runs at most: the text before the nick, the nick, the text after it. A chat row's + // nick begins it; a peer action's translated sentence may put it anywhere. const size_t textLen = std::strlen(l.text); - const char* nickEnd = l.text + ((l.nickLen && l.nickLen < textLen) ? l.nickLen : 0); + const bool span = l.nickLen > 0 && static_cast(l.nickBegin) + l.nickLen <= textLen; + const char* nickBegin = span ? l.text + l.nickBegin : l.text; + const char* nickEnd = span ? nickBegin + l.nickLen : l.text; float x = pad; const char* seg = row[r].b; while (seg < row[r].e) { - // The row splits at the nick boundary when it falls inside this row. - const char* segEnd = (seg < nickEnd && nickEnd < row[r].e) ? nickEnd : row[r].e; - const ImU32 col = (seg < nickEnd) ? nickCol : body; + // The row splits at either nick boundary that falls inside it. + const char* segEnd = row[r].e; + if (seg < nickBegin && nickBegin < segEnd) segEnd = nickBegin; + else if (seg < nickEnd && nickEnd < segEnd) segEnd = nickEnd; + const ImU32 col = (seg >= nickBegin && seg < nickEnd) ? nickCol : body; dl->AddText(font, px, ImVec2(x - o, y), outline, seg, segEnd); dl->AddText(font, px, ImVec2(x + o, y), outline, seg, segEnd); dl->AddText(font, px, ImVec2(x, y - o), outline, seg, segEnd); From 78d4a26d2954caa2744934a6c0cc5d1078514298 Mon Sep 17 00:00:00 2001 From: pelmentor Date: Sat, 10 Oct 2026 05:55:15 +0600 Subject: [PATCH 079/306] [l10n] the catalogue's audit folds A raw NUL byte in a string, like the escape, refuses its entry: the loader checked one string and a lookup returned another. A flag may appear once, so every accepted spec fits the formatter whole. A span the cut removes is -1, as the header says. A Plural-Forms that does not compile is said with its reason; a missing one says nothing. Refused entries are listed up to twenty per file, header text is printed safe, the override is opened while an editor holds it and a failure to open it is said, and Init's line names every source. A tag stops at an extension and is trimmed; auto matches in any case. The private headers move beside their sources, Tcn waits for a caller, and the selftest gains the rows it promised, its UTF-8 cut now able to fail. Co-Authored-By: Claude Opus 5.5 --- src/votv-coop/include/l10n/l10n.h | 10 ++- src/votv-coop/include/l10n/mark.h | 2 +- src/votv-coop/src/l10n/catalog.cpp | 5 +- src/votv-coop/src/l10n/catalog.h | 7 +- src/votv-coop/src/l10n/fmt.cpp | 11 +-- src/votv-coop/src/l10n/l10n.cpp | 87 +++++++++++------- src/votv-coop/src/l10n/l10n_selftest.cpp | 89 +++++++++++++++++-- src/votv-coop/src/l10n/locale_choice.cpp | 3 + src/votv-coop/src/l10n/locale_choice.h | 2 +- src/votv-coop/src/l10n/plural_expr.cpp | 2 +- .../{include => src}/l10n/plural_expr.h | 3 +- src/votv-coop/src/l10n/po_reader.cpp | 30 ++++--- .../{include => src}/l10n/po_reader.h | 0 src/votv-coop/src/l10n/printf_check.cpp | 16 ++-- .../{include => src}/l10n/printf_check.h | 4 +- src/votv-coop/src/l10n/quiet.h | 12 +++ 16 files changed, 204 insertions(+), 79 deletions(-) rename src/votv-coop/{include => src}/l10n/plural_expr.h (93%) rename src/votv-coop/{include => src}/l10n/po_reader.h (100%) rename src/votv-coop/{include => src}/l10n/printf_check.h (93%) create mode 100644 src/votv-coop/src/l10n/quiet.h diff --git a/src/votv-coop/include/l10n/l10n.h b/src/votv-coop/include/l10n/l10n.h index cb2e2fbce..07187228e 100644 --- a/src/votv-coop/include/l10n/l10n.h +++ b/src/votv-coop/include/l10n/l10n.h @@ -1,8 +1,8 @@ // l10n/l10n.h -- the mod's own interface text in the player's language. // -// The shape is MTA's (Client/core/CLocalization.cpp, Client/sdk/core/CLocalizationInterface.h): gettext -// catalogues, English as the msgid, four lookups -- T is `_`, Tc `_tc` (with a context), Tn `_tn` -// (plural), Tcn `_tcn` -- and a marker that does nothing at run time so the template extractor finds a +// The shape is MTA's (CLocalization.cpp, CLocalizationInterface.h): gettext +// catalogues, English as the msgid, the lookups -- T is `_`, Tc `_tc` (with a context), Tn `_tn` +// (plural); MTA's `_tcn` waits for a call site that needs a context and a plural at once -- and a marker that does nothing at run time so the template extractor finds a // string in a static table, translated where it is drawn (`_td`, here L10N_MARK). The reader, the plural // evaluator and the formatter are ours: MTA's parser, tinygettext, is GPL and is not vendored. // @@ -22,7 +22,6 @@ namespace l10n { const char* T (const char* msgid); const char* Tc (const char* ctx, const char* msgid); const char* Tn (const char* singular, const char* plural, unsigned long long n); -const char* Tcn(const char* ctx, const char* singular, const char* plural, unsigned long long n); // "###" for an id-bearing ImGui widget, composed into its own buffer: a temporary that lives // to the end of the full expression, so `ImGui::Button(l10n::Label(l10n::T("Apply"), "apply"))` is @@ -46,6 +45,9 @@ struct Span { // printf with argument numbers (`%2$s %1$s`), over the conversion grammar of l10n/printf_check.h. // Returns the bytes written, cut at the last whole UTF-8 sequence that fits and always terminated, or // -1 with an empty buffer when the format is refused. FmtSpan also reports where argument 1 landed. +// The arguments are read by the types the format names, as printf reads them: a call passing fewer +// arguments than its msgid's conversions is a defect at that call site, and nothing here can see it +// (a translation cannot cause it: its conversions are pinned to the msgid's at load). int Fmt (char* buf, size_t size, const char* fmt, ...); int FmtSpan(char* buf, size_t size, Span* arg1, const char* fmt, ...); diff --git a/src/votv-coop/include/l10n/mark.h b/src/votv-coop/include/l10n/mark.h index 1bd9d6282..e2ce088e4 100644 --- a/src/votv-coop/include/l10n/mark.h +++ b/src/votv-coop/include/l10n/mark.h @@ -1,6 +1,6 @@ // l10n/mark.h -- the markers that do nothing at run time: a literal in a static table (a config row's // label, an F1 tree name) wrapped so the template extractor finds it, translated where it is drawn. -// MTA's `_td` (Client/sdk/core/CLocalizationInterface.h). Alone in its header so a table's owner, such +// MTA's `_td` (CLocalizationInterface.h). Alone in its header so a table's owner, such // as coop/config, includes nothing else of l10n. #pragma once diff --git a/src/votv-coop/src/l10n/catalog.cpp b/src/votv-coop/src/l10n/catalog.cpp index 6a4a806f7..b3fd17d49 100644 --- a/src/votv-coop/src/l10n/catalog.cpp +++ b/src/votv-coop/src/l10n/catalog.cpp @@ -6,6 +6,7 @@ namespace l10n::detail { int Catalog::Insert(const po::File& f, int fallbackRule, SourceReport& report) { report.translator = f.lastTranslator; + report.team = f.languageTeam; report.refused.insert(report.refused.end(), f.refused.begin(), f.refused.end()); int rule = fallbackRule; if (!f.pluralForms.empty()) { @@ -15,10 +16,8 @@ int Catalog::Insert(const po::File& f, int fallbackRule, SourceReport& report) { rules_.push_back(std::move(r)); rule = static_cast(rules_.size() - 1); } else { - report.pluralNote = why; + report.pluralNote = why; // a header that carried one which did not compile } - } else { - report.pluralNote = "no Plural-Forms in the header"; } for (const po::Entry& e : f.entries) { if (e.plural) { diff --git a/src/votv-coop/src/l10n/catalog.h b/src/votv-coop/src/l10n/catalog.h index 181d557f7..3699a2400 100644 --- a/src/votv-coop/src/l10n/catalog.h +++ b/src/votv-coop/src/l10n/catalog.h @@ -5,8 +5,8 @@ // a lookup is one probe whatever the layering. Immutable once published; any thread may look up. #pragma once -#include "l10n/plural_expr.h" -#include "l10n/po_reader.h" +#include "plural_expr.h" +#include "po_reader.h" #include #include @@ -66,8 +66,9 @@ struct SourceReport { std::string refusal; // why the whole file was refused, when it was size_t inserted = 0; std::vector refused; // the reader's refused entries and the catalogue's - std::string pluralNote; // why this file's Plural-Forms did not parse, when it did not + std::string pluralNote; // why this file's Plural-Forms did not compile, when it did not std::string translator; // Last-Translator + std::string team; // Language-Team }; class Catalog { diff --git a/src/votv-coop/src/l10n/fmt.cpp b/src/votv-coop/src/l10n/fmt.cpp index 6ab963404..390ebe64d 100644 --- a/src/votv-coop/src/l10n/fmt.cpp +++ b/src/votv-coop/src/l10n/fmt.cpp @@ -8,7 +8,8 @@ // types the format names, and each conversion is written by snprintf from its own unnumbered spec. #include "l10n/l10n.h" -#include "l10n/printf_check.h" +#include "printf_check.h" +#include "quiet.h" #include "ue_wrap/core/log.h" @@ -103,7 +104,7 @@ int Format(char* buf, size_t size, Span* arg1, const char* fmt, va_list ap) { // Translations are checked at load and our English by the template gate, so this is a // defect in a literal: said once, never per frame. static std::atomic s_said{false}; - if (!s_said.exchange(true)) + if (!detail::g_selftestQuiet.load(std::memory_order_relaxed) && !s_said.exchange(true)) UE_LOGE("l10n: a format the conversion grammar refuses (%s): '%.80s'", why ? why : "?", fmt); return -1; } @@ -145,9 +146,9 @@ int Format(char* buf, size_t size, Span* arg1, const char* fmt, va_list ap) { } if (cut) pos = CutUtf8(buf, pos); buf[pos] = '\0'; - if (arg1 && arg1->begin >= 0 && static_cast(arg1->begin + arg1->len) > pos) { - arg1->len = static_cast(pos) - arg1->begin; - if (arg1->len <= 0) *arg1 = Span{}; + if (arg1 && arg1->begin >= 0) { + if (static_cast(arg1->begin + arg1->len) > pos) arg1->len = static_cast(pos) - arg1->begin; + if (arg1->len <= 0) *arg1 = Span{}; // none of argument 1 was kept } return static_cast(pos); } diff --git a/src/votv-coop/src/l10n/l10n.cpp b/src/votv-coop/src/l10n/l10n.cpp index a67471657..670772aa7 100644 --- a/src/votv-coop/src/l10n/l10n.cpp +++ b/src/votv-coop/src/l10n/l10n.cpp @@ -4,7 +4,8 @@ #include "catalog.h" #include "locale_choice.h" -#include "l10n/po_reader.h" +#include "po_reader.h" +#include "quiet.h" #include "ue_wrap/core/log.h" #include "ue_wrap/core/paths.h" @@ -28,9 +29,7 @@ std::string g_locale; // written before g_cat's release store, read after its const Entry* Lookup(const char* ctx, const char* msgid) { const Catalog* c = g_cat.load(std::memory_order_acquire); - if (!c || !msgid) return nullptr; - const Entry* e = c->Find(ctx, msgid); - return (e && !e->forms.empty()) ? e : nullptr; + return (c && msgid) ? c->Find(ctx, msgid) : nullptr; // an entry always holds a form } const char* Plural(const char* ctx, const char* singular, const char* plural, unsigned long long n) { @@ -58,11 +57,18 @@ std::string_view Resource(const std::wstring& name) { return std::string_view(static_cast(p), size); } -// A file of at most one byte past the reader's cap, so an oversize file is refused by the reader. +// A file of at most one byte past the reader's cap, so an oversize file is refused by the reader. Shared +// for writing and deleting too: a translator's editor holds the file open while the game starts. A file +// that exists but cannot be read is said; one that is not there is the usual case and says nothing. bool ReadFileBytes(const std::wstring& path, std::string& out) { - HANDLE h = ::CreateFileW(path.c_str(), GENERIC_READ, FILE_SHARE_READ, nullptr, OPEN_EXISTING, - FILE_ATTRIBUTE_NORMAL, nullptr); - if (h == INVALID_HANDLE_VALUE) return false; + HANDLE h = ::CreateFileW(path.c_str(), GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr); + if (h == INVALID_HANDLE_VALUE) { + const DWORD err = ::GetLastError(); + if (err != ERROR_FILE_NOT_FOUND && err != ERROR_PATH_NOT_FOUND) + UE_LOGW("l10n: '%ls' exists but could not be opened (error %lu) -- it is not used", path.c_str(), err); + return false; + } out.resize(po::kMaxFileBytes + 1); DWORD got = 0; const BOOL ok = ::ReadFile(h, out.data(), static_cast(out.size()), &got, nullptr); @@ -74,6 +80,14 @@ bool ReadFileBytes(const std::wstring& path, std::string& out) { std::wstring Widen(const std::string& s) { return std::wstring(s.begin(), s.end()); } // ASCII tags +// Header text from a translator's file, for a log line: control bytes become spaces. +std::string Printable(const std::string& s) { + std::string out = s.substr(0, 120); + for (char& c : out) + if (static_cast(c) < 0x20 || c == 0x7F) c = ' '; + return out; +} + std::string ResourceName(const std::string& level) { std::string n = "L10N_"; for (char c : level) n.push_back((c >= 'a' && c <= 'z') ? static_cast(c - 'a' + 'A') : c); @@ -87,23 +101,30 @@ int Load(Catalog& cat, const std::string& name, const std::string& level, std::s if (!f.ok) { UE_LOGW("l10n: %s refused whole (line %d: %s) -- nothing of it is used", name.c_str(), f.refusal.line, f.refusal.what.c_str()); - summary += "; " + name + " refused"; + summary += ", " + name + " refused"; return fallbackRule; } if (f.charsetAssumed) UE_LOGI("l10n: %s names no charset -- read as UTF-8", name.c_str()); if (!f.language.empty() && detail::NormaliseTag(f.language) != level) - UE_LOGW("l10n: %s says Language '%s' but is loaded as %s", name.c_str(), f.language.c_str(), level.c_str()); + UE_LOGW("l10n: %s says Language '%s' but is loaded as %s", name.c_str(), Printable(f.language).c_str(), + level.c_str()); detail::SourceReport rep; rep.name = name; const int rule = cat.Insert(f, fallbackRule, rep); - if (!rep.pluralNote.empty() && rule >= 0 && rule == fallbackRule) - UE_LOGI("l10n: %s has no usable Plural-Forms (%s) -- its plural entries take the pack's rule", - name.c_str(), rep.pluralNote.c_str()); - for (const po::Diag& d : rep.refused) - UE_LOGW("l10n: %s line %d refused: %s", name.c_str(), d.line, d.what.c_str()); + if (!rep.pluralNote.empty()) + UE_LOGW("l10n: %s has a Plural-Forms that does not compile (%s) -- %s", name.c_str(), + rep.pluralNote.c_str(), rule >= 0 ? "its plural entries take the pack's rule" + : "its plural entries are refused"); + // One line per refused entry up to a cap, then the count: a broken file must not flood the log. + constexpr size_t kSaidMax = 20; + for (size_t i = 0; i < rep.refused.size() && i < kSaidMax; ++i) + UE_LOGW("l10n: %s line %d refused: %s", name.c_str(), rep.refused[i].line, rep.refused[i].what.c_str()); + if (rep.refused.size() > kSaidMax) + UE_LOGW("l10n: %s -- %zu more refused entries not listed", name.c_str(), rep.refused.size() - kSaidMax); refusedTotal += rep.refused.size(); - summary += "; " + name + " " + std::to_string(rep.inserted) + " entries"; - if (!rep.translator.empty()) summary += " (translator " + rep.translator + ")"; + summary += ", " + name + " " + std::to_string(rep.inserted) + " entries"; + if (!rep.translator.empty()) summary += " (translator " + Printable(rep.translator) + ")"; + if (!rep.team.empty()) summary += " (team " + Printable(rep.team) + ")"; return rule; } @@ -123,10 +144,6 @@ const char* Tn(const char* singular, const char* plural, unsigned long long n) { return Plural(nullptr, singular, plural, n); } -const char* Tcn(const char* ctx, const char* singular, const char* plural, unsigned long long n) { - return Plural(ctx, singular, plural, n); -} - Label::Label(const char* text, const char* id) { if (!text) text = ""; if (!id) id = ""; @@ -134,7 +151,8 @@ Label::Label(const char* text, const char* id) { if (idLen > 64) { // An id is a literal of ours; one this long is a defect at its call site, said once. static std::atomic s_said{false}; - if (!s_said.exchange(true)) UE_LOGE("l10n: a widget id of %zu bytes ('%.32s') -- keep ids short", idLen, id); + if (!detail::g_selftestQuiet.load(std::memory_order_relaxed) && !s_said.exchange(true)) + UE_LOGE("l10n: a widget id of %zu bytes ('%.32s') -- keep ids short", idLen, id); } // The id is kept whole and the text is cut to fit, at a UTF-8 boundary: a cut id would collide. const size_t tail = 3 + idLen; @@ -172,9 +190,11 @@ size_t FoundCount() { void Init(const char* language) { static std::atomic s_done{false}; if (s_done.exchange(true)) return; - const std::string requested = (language && *language) ? language : "auto"; + std::string requested = (language && *language) ? language : "auto"; + std::string lower; + for (char ch : requested) lower.push_back((ch >= 'A' && ch <= 'Z') ? static_cast(ch - 'A' + 'a') : ch); std::string why, tag; - if (requested == "auto") { + if (lower == "auto") { const std::string win = detail::WindowsDisplayLanguage(); tag = detail::NormaliseTag(win); why = "auto: Windows display language " + (win.empty() ? std::string("unknown") : win); @@ -196,26 +216,31 @@ void Init(const char* language) { auto* cat = new Catalog(); // immortal: see g_cat size_t refused = 0; std::string summary; - const std::wstring dir = ue_wrap::paths::ExeDir() + L"\\multivoid_locale\\"; + // An exe dir that could not be read would put the folder at the drive's root: no overrides then. + const std::wstring exeDir = ue_wrap::paths::ExeDir(); + const std::wstring dir = exeDir.empty() ? std::wstring() : exeDir + L"\\multivoid_locale\\"; for (int i = 0; i < nLevels; ++i) { const std::string& level = levels[i]; int packRule = -1; const std::string_view pack = Resource(Widen(ResourceName(level))); - if (!pack.empty()) - packRule = Load(*cat, "embedded pack " + level, level, pack, -1, refused, summary); + if (!pack.empty()) packRule = Load(*cat, "embedded pack " + level, level, pack, -1, refused, summary); + else summary += ", embedded pack " + level + " none"; std::string bytes; - if (ReadFileBytes(dir + Widen(level) + L".po", bytes)) + if (!dir.empty() && ReadFileBytes(dir + Widen(level) + L".po", bytes)) Load(*cat, "override " + level + ".po", level, bytes, packRule, refused, summary); + else + summary += ", override " + level + ".po none"; } + // D8's line: what was chosen, why, and what every source contributed. + const char* sources = summary.empty() ? "" : summary.c_str() + 2; if (cat->Size() == 0) { - UE_LOGI("l10n: language English (%s) -- no catalogue for %s%s", why.c_str(), tag.c_str(), - summary.empty() ? ", none embedded or in multivoid_locale" : summary.c_str()); + UE_LOGI("l10n: language English (%s) -- no catalogue for %s: %s", why.c_str(), tag.c_str(), sources); delete cat; return; } g_locale = tag; g_cat.store(cat, std::memory_order_release); - UE_LOGI("l10n: language %s (%s)%s, %zu refused", tag.c_str(), why.c_str(), summary.c_str(), refused); + UE_LOGI("l10n: language %s (%s) -- %s, %zu refused", tag.c_str(), why.c_str(), sources, refused); } } // namespace l10n diff --git a/src/votv-coop/src/l10n/l10n_selftest.cpp b/src/votv-coop/src/l10n/l10n_selftest.cpp index 14de32342..fbccf53e1 100644 --- a/src/votv-coop/src/l10n/l10n_selftest.cpp +++ b/src/votv-coop/src/l10n/l10n_selftest.cpp @@ -9,9 +9,10 @@ #include "catalog.h" #include "locale_choice.h" -#include "l10n/plural_expr.h" -#include "l10n/po_reader.h" -#include "l10n/printf_check.h" +#include "plural_expr.h" +#include "po_reader.h" +#include "printf_check.h" +#include "quiet.h" #include "ue_wrap/core/log.h" @@ -23,6 +24,11 @@ #include namespace l10n { + +namespace detail { +std::atomic g_selftestQuiet{false}; +} // namespace detail + namespace { #include "plural_vectors.inc" @@ -90,6 +96,7 @@ void Conversions(Checks& c) { c.Ok(!Accepts("%ls") && !Accepts("%c") && !Accepts("%hd") && !Accepts("%lf"), "%ls, %c, %hd and %lf are refused"); c.Ok(!Accepts("%.3s") && !Accepts("%05s") && !Accepts("%+u") && !Accepts("%#d"), "flags C leaves undefined are refused"); c.Ok(!Accepts("%123d") && !Accepts("100% sure"), "a three-digit width and a bare '%' are refused"); + c.Ok(!Accepts("%--5d") && !Accepts("%00x") && Accepts("%-08.2f"), "a flag repeated is refused, distinct flags are not"); c.Ok(Match("%s took %d", "%2$d by %1$s"), "a reordered translation matches"); c.Ok(Match("%d items", "%i items") && Match("%5.2f", "%.1f"), "d and i are one type; width and precision may differ"); c.Ok(!Match("%s took %d", "%s took %s") && !Match("%d", "%ld"), "a type or length change is refused"); @@ -103,12 +110,15 @@ void Formatter(Checks& c) { Span sp; c.Ok(FmtSpan(buf, sizeof(buf), &sp, "%2$s greets %1$s.", "Nick", "Bob") > 0 && sp.begin == 11 && sp.len == 4, "FmtSpan reports where argument 1 landed"); - // A cut never splits a sequence: U+4E2D is three bytes, and four fit before the cut. - char small[6]; - const int n = Fmt(small, sizeof(small), "ab%s", "\xE4\xB8\xAD\xE4\xB8\xAD"); - c.Ok(n == 5 && std::strcmp(small, "ab\xE4\xB8\xAD") == 0, "a cut keeps whole UTF-8 sequences"); - c.Ok(FmtSpan(small, sizeof(small), &sp, "abcd%s", "Nick") == 5 && sp.begin == 4 && sp.len == 1, + // A cut never splits a sequence: U+4E2D is three bytes, and only two of them fit after "ab". + char small[5]; + const int n = Fmt(small, sizeof(small), "ab%s", "\xE4\xB8\xAD"); + c.Ok(n == 2 && std::strcmp(small, "ab") == 0, "a cut keeps whole UTF-8 sequences"); + c.Ok(FmtSpan(small, sizeof(small), &sp, "abc%s", "Nick") == 4 && sp.begin == 3 && sp.len == 1, "a span the cut reaches is clipped"); + c.Ok(FmtSpan(small, sizeof(small), &sp, "abcde%s", "Nick") == 4 && sp.begin == -1, + "a span the cut removes whole is no span"); + c.Ok(Fmt(buf, sizeof(buf), "%s and %1$s", "x") == -1 && buf[0] == '\0', "a refused format writes nothing"); const Label l("Apply", "apply"); c.Ok(std::strcmp(l, "Apply###apply") == 0, "Label composes text and id"); std::string longText(300, 'x'); @@ -116,6 +126,16 @@ void Formatter(Checks& c) { const size_t len = std::strlen(cut); c.Ok(len == 255 && std::strcmp(static_cast(cut) + len - 10, "###keep_me") == 0, "a long label is cut in its text and keeps its id"); + // 84 three-byte characters are 252 bytes; with "###id" (5) the text has 250 bytes of room, which + // ends inside the 84th, so the text keeps 83 of them. + std::string han; + for (int i = 0; i < 84; ++i) han += "\xE4\xB8\xAD"; + const Label hanCut(han.c_str(), "id"); + c.Ok(std::strlen(hanCut) == 83 * 3 + 5 && std::strcmp(static_cast(hanCut) + 83 * 3, "###id") == 0, + "a label's cut never splits a character"); + const std::string longId(100, 'i'); + const Label idLong("x", longId.c_str()); + c.Ok(std::strlen(idLong) == 1 + 3 + 100, "an id longer than 64 bytes is still kept whole"); } void Tags(Checks& c) { @@ -127,6 +147,8 @@ void Tags(Checks& c) { NormaliseTag("sr-Latn") == "sr" && NormaliseTag("es-419") == "es_419", "regions and scripts elsewhere"); c.Ok(NormaliseTag("").empty() && NormaliseTag("x").empty() && NormaliseTag("12-AB").empty() && NormaliseTag("auto!").empty(), "garbage is no tag"); + c.Ok(NormaliseTag("en-u-ca-gregory") == "en" && NormaliseTag(" zh_CN ") == "zh_CN", + "an extension is no region, and blanks are trimmed"); } void Reader(Checks& c) { @@ -176,6 +198,37 @@ void Reader(Checks& c) { c.Ok(tmpl.ok && tmpl.charsetAssumed, "a template's CHARSET is read as UTF-8"); std::string big(po::kMaxFileBytes + 1, '#'); c.Ok(!po::Read(big).ok, "a file over 1 MiB is refused"); + + // The entry-level rows of D1 that kGood does not reach, one file each. + auto entryRefused = [](const std::string& body) { + const po::File g = po::Read(body); + return g.ok && g.entries.empty() && g.refused.size() == 1; + }; + c.Ok(entryRefused("msgid \"%llu a\"\nmsgid_plural \"%llu as\"\nmsgstr \"%llu b\"\n"), + "a msgid_plural with a plain msgstr is refused"); + c.Ok(entryRefused("msgid \"a\"\nmsgstr[0] \"b\"\n"), "msgstr[n] without msgid_plural is refused"); + c.Ok(entryRefused("msgid \"%llu a\"\nmsgid_plural \"%llu as\"\nmsgstr[1] \"%llu b\"\nmsgstr[0] \"%llu c\"\n"), + "plural forms out of order are refused"); + c.Ok(entryRefused("msgid \"a\"\nmsgstr \"\\x414\"\n"), "a hex escape of three digits is refused"); + c.Ok(entryRefused(std::string("msgid \"a\"\nmsgstr \"") + std::string(17000, 'b') + "\"\n"), + "an entry over 16 KiB is refused"); + std::string nine = "msgid \"%llu a\"\nmsgid_plural \"%llu as\"\n"; + for (int k = 0; k < 9; ++k) nine += "msgstr[" + std::to_string(k) + "] \"%llu x\"\n"; + c.Ok(entryRefused(nine), "more than 8 plural forms are refused"); + c.Ok(entryRefused("msgid \"%s and %d\"\nmsgid_plural \"%s and %d\"\nmsgstr[0] \"%s\"\nmsgstr[1] \"%s and %d\"\n"), + "a plural form missing a conversion is refused"); + c.Ok(entryRefused(std::string("msgid \"a\"\nmsgstr \"b") + '\0' + "c\"\n"), "a raw NUL byte in a string is refused"); + const po::File allEmpty = po::Read("msgid \"%llu a\"\nmsgid_plural \"%llu as\"\nmsgstr[0] \"\"\nmsgstr[1] \"\"\n"); + c.Ok(allEmpty.ok && allEmpty.entries.empty() && allEmpty.refused.empty(), "a plural with every form empty is untranslated"); + const po::File twoIds = po::Read("msgid \"\"\nmsgid \"A\"\nmsgstr \"a\"\n"); + c.Ok(twoIds.ok && twoIds.entries.size() == 1 && twoIds.entries[0].id == "A", "a second msgid starts a new entry"); + c.Ok(!po::Read("\"stray\"\n").ok, "a string line with no keyword refuses the file"); + const po::File crlf = po::Read("msgid \"\"\r\nmsgstr \"Language: de\\n\"\r\n\r\nmsgid \"a\"\r\nmsgstr \"b\"\r\n"); + c.Ok(crlf.ok && crlf.language == "de" && crlf.entries.size() == 1, "a CRLF file loads"); + const po::File late = po::Read("msgid \"a\"\nmsgstr \"b\"\n\nmsgid \"\"\nmsgstr \"Language: fr\\n\"\n"); + c.Ok(late.ok && late.language == "fr" && late.entries.size() == 1, "a header after the entries is read"); + const po::File badLine = po::Read("msgid \"a\"\nmsgstr \"b\"\n\nmsgid \"c\"\nmsgstr \"\xC3\x28\"\n"); + c.Ok(!badLine.ok && badLine.refusal.line == 4, "a file-level refusal names its entry's line"); } void Merge(Checks& c) { @@ -205,6 +258,24 @@ void Merge(Checks& c) { pl->forms[cat.SelectForm(*pl, 5)] == "%llu muchos", "an override's plural entry takes the pack's rule"); c.Ok(!cat.Peek(nullptr, "A")->hit.load() && cat.Find(nullptr, "A") && cat.Peek(nullptr, "A")->hit.load() && cat.FoundCount() == 1, "a found lookup marks its entry; a peek does not"); + + // A count of forms other than the rule's, and a Plural-Forms that does not compile, with and + // without a pack rule to fall back on. + detail::Catalog c2; + detail::SourceReport s1, s2, s3; + const std::string three = std::string(hdr) + + "msgid \"%llu n\"\nmsgid_plural \"%llu ns\"\nmsgstr[0] \"a\"\nmsgstr[1] \"b\"\nmsgstr[2] \"c\"\n"; + c2.Insert(po::Read(three), -1, s1); + c.Ok(c2.Peek(nullptr, "%llu n") == nullptr && s1.refused.size() == 1, "a count of forms other than nplurals is refused"); + const std::string broken = "msgid \"\"\nmsgstr \"Plural-Forms: nplurals=2; plural=n ^ 1;\\n\"\n\n" + "msgid \"%llu n\"\nmsgid_plural \"%llu ns\"\nmsgstr[0] \"%llu a\"\nmsgstr[1] \"%llu b\"\n"; + detail::Catalog c3; + c3.Insert(po::Read(broken), -1, s2); + c.Ok(c3.Peek(nullptr, "%llu n") == nullptr && !s2.pluralNote.empty(), "a pack whose Plural-Forms does not compile refuses its plurals"); + const int pr = c3.Insert(po::Read(std::string(hdr)), -1, s3); + detail::SourceReport s4; + c3.Insert(po::Read(broken), pr, s4); + c.Ok(c3.Peek(nullptr, "%llu n") != nullptr && !s4.pluralNote.empty(), "an override's uncompilable rule falls back to the pack's"); } // Every pack this build embeds: named L10N_, and each must load with nothing refused. @@ -239,6 +310,7 @@ void Embedded(Checks& c) { } // namespace bool RunSelftest(bool injectRed) { + detail::g_selftestQuiet.store(true, std::memory_order_relaxed); Checks c; Plurals(c, injectRed); Conversions(c); @@ -247,6 +319,7 @@ bool RunSelftest(bool injectRed) { Reader(c); Merge(c); Embedded(c); + detail::g_selftestQuiet.store(false, std::memory_order_relaxed); if (c.pass == c.total) { UE_LOGI("l10n selftest: ALL PASS (%d checks)", c.total); return true; diff --git a/src/votv-coop/src/l10n/locale_choice.cpp b/src/votv-coop/src/l10n/locale_choice.cpp index 47c312b70..69d5344d2 100644 --- a/src/votv-coop/src/l10n/locale_choice.cpp +++ b/src/votv-coop/src/l10n/locale_choice.cpp @@ -21,6 +21,8 @@ std::string NormaliseTag(std::string_view tag) { // A gettext locale may carry an encoding or a modifier: `zh_CN.UTF-8`, `sr_RS@latin`. const size_t cut = tag.find_first_of(".@"); if (cut != std::string_view::npos) tag = tag.substr(0, cut); + while (!tag.empty() && (tag.front() == ' ' || tag.front() == '\t')) tag.remove_prefix(1); + while (!tag.empty() && (tag.back() == ' ' || tag.back() == '\t')) tag.remove_suffix(1); std::vector parts; size_t at = 0; while (at <= tag.size()) { @@ -37,6 +39,7 @@ std::string NormaliseTag(std::string_view tag) { std::string script, region; for (size_t i = 1; i < parts.size(); ++i) { const std::string_view p = parts[i]; + if (p.size() == 1) break; // a BCP-47 extension or private-use singleton: what follows is not a region bool alpha = !p.empty(), digits = !p.empty(); for (char c : p) { alpha = alpha && IsAlpha(c); diff --git a/src/votv-coop/src/l10n/locale_choice.h b/src/votv-coop/src/l10n/locale_choice.h index d5a5a2a5d..58597d362 100644 --- a/src/votv-coop/src/l10n/locale_choice.h +++ b/src/votv-coop/src/l10n/locale_choice.h @@ -4,7 +4,7 @@ // a player with an English Windows and Russian number formats has -- through // GetUserPreferredUILanguages, the SDK's current call (GetUserDefaultUILanguage and LCIDToLocaleName are // marked deprecated in winnls.h). MTA stores a `locale` setting its installer chose, default en_US -// (Client/core/CLocalization.cpp); the mod has no installer, so the OS is the only signal at first start. +// (CLocalization.cpp); the mod has no installer, so the OS is the only signal at first start. #pragma once #include diff --git a/src/votv-coop/src/l10n/plural_expr.cpp b/src/votv-coop/src/l10n/plural_expr.cpp index c2dd777ba..655808967 100644 --- a/src/votv-coop/src/l10n/plural_expr.cpp +++ b/src/votv-coop/src/l10n/plural_expr.cpp @@ -1,6 +1,6 @@ // l10n/plural_expr.cpp -- see l10n/plural_expr.h. -#include "l10n/plural_expr.h" +#include "plural_expr.h" #include diff --git a/src/votv-coop/include/l10n/plural_expr.h b/src/votv-coop/src/l10n/plural_expr.h similarity index 93% rename from src/votv-coop/include/l10n/plural_expr.h rename to src/votv-coop/src/l10n/plural_expr.h index f01203fc6..f7262aa79 100644 --- a/src/votv-coop/include/l10n/plural_expr.h +++ b/src/votv-coop/src/l10n/plural_expr.h @@ -3,7 +3,7 @@ // The header's C expression over `n` picks which of a translation's forms a count takes, and a // language's rule is whatever its translators wrote there, so it is EVALUATED, as libintl evaluates // it, rather than matched against a table of known strings -- tinygettext's table -// (vendor/tinygettext/plural_forms.cpp:50-85) misses the rule of 12 of MTA's own 43 locales. The +// (tinygettext's plural_forms.cpp:50-85) misses the rule of 12 of MTA's own 43 locales. The // grammar is GNU gettext's (plural.y): `n`, decimal integers, `( )`, `!`, `* / %`, `+ -`, // `< > <= >=`, `== !=`, `&&`, `||`, `?:`, with C's precedence and associativity, over unsigned 64-bit // values. The text is untrusted translator input: it is bounded in length and nesting, and compiled @@ -32,7 +32,6 @@ class Rule { unsigned Select(uint64_t n) const; unsigned Count() const { return nplurals_; } - bool Valid() const { return nplurals_ != 0; } private: struct Node { diff --git a/src/votv-coop/src/l10n/po_reader.cpp b/src/votv-coop/src/l10n/po_reader.cpp index 438728e14..cf39657d9 100644 --- a/src/votv-coop/src/l10n/po_reader.cpp +++ b/src/votv-coop/src/l10n/po_reader.cpp @@ -1,8 +1,8 @@ // l10n/po_reader.cpp -- see l10n/po_reader.h. -#include "l10n/po_reader.h" +#include "po_reader.h" -#include "l10n/printf_check.h" +#include "printf_check.h" #define WIN32_LEAN_AND_MEAN #include @@ -39,7 +39,8 @@ StrResult ReadString(std::string_view s, size_t& i, std::string& out, const char while (i < s.size()) { const char c = s[i++]; if (c == '"') return bad ? StrResult::BadEscape : StrResult::Ok; - if (c == '\n') return StrResult::Unterminated; + // A raw NUL would cut the C string a lookup returns, as the escape would. + if (c == '\0') { badEscape("a NUL byte in a string"); continue; } if (c != '\\') { out.push_back(c); continue; } if (i >= s.size()) return StrResult::Unterminated; const char e = s[i++]; @@ -119,6 +120,7 @@ struct Building { Entry e; bool fuzzy = false; bool any = false; // a keyword has been read for it + bool sawId = false; // its msgid has been read bool sawStr = false; // a msgstr has been read: the next msgctxt/msgid starts a new entry int plainStr = 0; // msgstr lines without an index int indexedStr = 0; // msgstr[n] lines @@ -146,6 +148,8 @@ File Read(std::string_view bytes) { std::string header; bool haveHeader = false; + int headerLine = 0; + int badLine = 0; // the line a file-level refusal from finish() names bool pendingFuzzy = false; Building b; @@ -156,9 +160,9 @@ File Read(std::string_view bytes) { for (const std::string& s : e.str) total += s.size(); if (!b.bad && total > kMaxEntryBytes) { b.bad = true; b.why = "an entry over 16 KiB"; } for (const std::string* s : {&e.ctx, &e.id, &e.idPlural}) - if (!ValidUtf8(*s)) return "bytes that are not UTF-8"; + if (!ValidUtf8(*s)) { badLine = b.line; return "bytes that are not UTF-8"; } for (const std::string& s : e.str) - if (!ValidUtf8(s)) return "bytes that are not UTF-8"; + if (!ValidUtf8(s)) { badLine = b.line; return "bytes that are not UTF-8"; } if (!b.bad && e.str.empty()) { b.bad = true; b.why = "an entry with no msgstr"; } // A plural entry has a msgid_plural and only msgstr[n]; a singular one exactly one msgstr. const bool pluralShape = !e.idPlural.empty() && b.plainStr == 0 && b.indexedStr > 0; @@ -169,7 +173,7 @@ File Read(std::string_view bytes) { } const bool isHeader = !e.hasCtx && e.id.empty() && !e.plural; if (isHeader) { - if (!haveHeader && !b.bad) { header = e.str[0]; haveHeader = true; } + if (!haveHeader && !b.bad) { header = e.str[0]; haveHeader = true; headerLine = b.line; } } else if (b.bad) { f.refused.push_back(Diag{b.line, b.why ? b.why : "a malformed entry"}); } else if (!b.fuzzy) { @@ -230,7 +234,7 @@ File Read(std::string_view bytes) { // kind -- references, extracted and translator comments, `#|` previous, `#~` obsolete -- // is ignored. if (i + 1 < ln.size() && ln[i + 1] == ',') { - if (b.sawStr) { if (const char* w = finish()) return refuse(line, w); } + if (b.sawStr) { if (const char* w = finish()) return refuse(badLine, w); } const std::string_view flags = ln.substr(i + 2); for (size_t p = 0; (p = flags.find("fuzzy", p)) != std::string_view::npos; p += 5) { const bool l = p == 0 || flags[p - 1] == ' ' || flags[p - 1] == ',' || flags[p - 1] == '\t'; @@ -256,13 +260,15 @@ File Read(std::string_view bytes) { const std::string_view kw = ln.substr(i, k - i); std::string* target = nullptr; if (kw == "msgctxt" || kw == "msgid") { - if (b.sawStr || (kw == "msgctxt" && b.any) || (kw == "msgid" && !b.e.id.empty())) { - if (const char* w = finish()) return refuse(line, w); + // A new entry: after a msgstr, or a second msgctxt, or a second msgid (an empty one + // included) -- the last two leave an entry with no msgstr, which finish() refuses. + if (b.sawStr || (kw == "msgctxt" && b.any) || (kw == "msgid" && b.sawId)) { + if (const char* w = finish()) return refuse(badLine, w); } if (!b.any) { b.fuzzy = pendingFuzzy; pendingFuzzy = false; b.line = line; } b.any = true; if (kw == "msgctxt") { b.e.hasCtx = true; target = &b.e.ctx; } - else { target = &b.e.id; b.line = line; } + else { target = &b.e.id; b.line = line; b.sawId = true; } } else if (kw == "msgid_plural") { if (!b.any) return refuse(line, "a msgid_plural with no msgid"); b.e.plural = true; @@ -318,7 +324,7 @@ File Read(std::string_view bytes) { at = next; if (end == bytes.size()) break; } - if (const char* w = finish()) return refuse(line, w); + if (const char* w = finish()) return refuse(badLine, w); if (haveHeader) { f.pluralForms = HeaderField(header, "Plural-Forms"); @@ -336,7 +342,7 @@ File Read(std::string_view bytes) { while (!v.empty() && IsSpace(v.back())) v.remove_suffix(1); if (v == "CHARSET") f.charsetAssumed = true; else if (!EqualsNoCase(v, "UTF-8") && !EqualsNoCase(v, "UTF8")) - return refuse(0, "a charset other than UTF-8"); + return refuse(headerLine, "a charset other than UTF-8"); } } else { f.charsetAssumed = true; diff --git a/src/votv-coop/include/l10n/po_reader.h b/src/votv-coop/src/l10n/po_reader.h similarity index 100% rename from src/votv-coop/include/l10n/po_reader.h rename to src/votv-coop/src/l10n/po_reader.h diff --git a/src/votv-coop/src/l10n/printf_check.cpp b/src/votv-coop/src/l10n/printf_check.cpp index a8be3f38f..32788bca1 100644 --- a/src/votv-coop/src/l10n/printf_check.cpp +++ b/src/votv-coop/src/l10n/printf_check.cpp @@ -1,6 +1,6 @@ // l10n/printf_check.cpp -- see l10n/printf_check.h. -#include "l10n/printf_check.h" +#include "printf_check.h" namespace l10n::printf_check { namespace { @@ -47,14 +47,18 @@ bool Parse(std::string_view s, Parsed* out, const char** why) { c.arg = static_cast(out->count + 1); } c.specBegin = static_cast(i); - bool zero = false, sign = false, alt = false, prec = false; + bool zero = false, sign = false, alt = false, prec = false, left = false, plus = false, space = false; + // Each flag at most once, so the longest spec (5 flags, 2 width, '.', 2 precision, 2 length, + // the type) fits the formatter's buffer whole. for (; i < s.size(); ++i) { const char g = s[i]; - if (g == '0') zero = true; - else if (g == '+' || g == ' ') sign = true; - else if (g == '#') alt = true; - else if (g != '-') break; + bool* seen = g == '0' ? &zero : g == '+' ? &plus : g == ' ' ? &space : g == '#' ? &alt + : g == '-' ? &left : nullptr; + if (!seen) break; + if (*seen) return fail("a repeated flag"); + *seen = true; } + sign = plus || space; if (i < s.size() && s[i] == '*') return fail("a '*' width or precision"); for (int d = 0; i < s.size() && IsDigit(s[i]); ++d, ++i) if (d == 2) return fail("a width of more than two digits"); diff --git a/src/votv-coop/include/l10n/printf_check.h b/src/votv-coop/src/l10n/printf_check.h similarity index 93% rename from src/votv-coop/include/l10n/printf_check.h rename to src/votv-coop/src/l10n/printf_check.h index 1d6300cae..f3dd62581 100644 --- a/src/votv-coop/include/l10n/printf_check.h +++ b/src/votv-coop/src/l10n/printf_check.h @@ -2,8 +2,8 @@ // load (a translation against its msgid) and by l10n::Fmt (a format before it is written). // // A translation is untrusted text that reaches a formatter, so its conversions are a closed set: -// `%[n$][flags][width][.prec][length]type`, flags from `-+ #0` where C defines them for the type, -// width and precision of at most two decimal digits (no precision on `s`, which counts bytes and would +// `%[n$][flags][width][.prec][length]type`, flags from `-+ #0` (each at most once) where C defines +// them for the type, width and precision of at most two decimal digits (no precision on `s`, which counts bytes and would // cut a name), length `l` `ll` `z` with an integer type only, types `d i u x X o f F e E g G s`, and // `%%`. `*` (an argument no call site passes), `%n` (a write through a pointer), `%c` (a NUL or half a // UTF-8 sequence), wide `%ls` and every other length are refused. Numbering is all-unnumbered (the diff --git a/src/votv-coop/src/l10n/quiet.h b/src/votv-coop/src/l10n/quiet.h new file mode 100644 index 000000000..2de018a02 --- /dev/null +++ b/src/votv-coop/src/l10n/quiet.h @@ -0,0 +1,12 @@ +// l10n/quiet.h -- the selftest's quiet switch (private to src/l10n/). While the selftest drives the +// refusals on purpose, the call-site defect lines of Fmt and Label -- said once per process -- are +// neither printed nor spent, so a real defect later is still said. +#pragma once + +#include + +namespace l10n::detail { + +extern std::atomic g_selftestQuiet; + +} // namespace l10n::detail From 4ca6516d53c859600e9f866fa0ec394165afc4eb Mon Sep 17 00:00:00 2001 From: pelmentor Date: Sat, 10 Oct 2026 05:56:54 +0600 Subject: [PATCH 080/306] [dev] the chat drill's cut case places its nick past the cut The span arm's cut line put its nick at byte 262, which a byte offset cannot hold: it wrapped to 6, inside the kept text, and the arm failed on its own line. The nick now begins at byte 252 and ends one past the 255 bytes a line keeps, the only way a real span meets the cut, since the feed formats into a 256-byte buffer. Co-Authored-By: Claude Opus 5.5 --- src/votv-coop/src/coop/dev/chat_drill.cpp | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/votv-coop/src/coop/dev/chat_drill.cpp b/src/votv-coop/src/coop/dev/chat_drill.cpp index b68d6a85e..76dec4600 100644 --- a/src/votv-coop/src/coop/dev/chat_drill.cpp +++ b/src/votv-coop/src/coop/dev/chat_drill.cpp @@ -324,7 +324,10 @@ void JudgeSeed() { // feed, not a catalogue: the lines are pushed directly, as the peer-action feed pushes its own. void JudgeSpan() { const std::string mid = "[chat-drill] span: before Nick after"; - const std::string cut = "[chat-drill] spancut " + std::string(240, 'x') + " Nick"; + // The nick begins at byte 252 and ends at 256, one past the 255 bytes a line keeps: the feed + // formats into a 256-byte buffer, so no span begins past 255. + std::string cut = "[chat-drill] spancut "; + cut += std::string(252 - cut.size(), 'x') + "Nick"; const auto at = static_cast(mid.find("Nick")); CF::PushAction(mid, at, 4, 0xFFFFFFFFu); CF::PushAction(cut, static_cast(cut.size() - 4), 4, 0xFFFFFFFFu); From 0c664f1a39e64ba3d00429668076c9f33d137577 Mon Sep 17 00:00:00 2001 From: pelmentor Date: Sat, 10 Oct 2026 06:09:02 +0600 Subject: [PATCH 081/306] [ui] the F1 tree in the player's language, and the l10n drill The menu's player entries are marked and drawn translated, each under a fixed id, so the English name stays the selection key and the widget's id; developer entries stay English. The breadcrumb and the menu's hints are looked up too. The render thread publishes the pane it last drew, the readiness the new l10n drill waits on: it opens World > Rules and Network > Stats, waits for the join line in either language, and judges them against its row, translated or english, so a per-peer arm can pass on both peers; translated under English is the red. Co-Authored-By: Claude Opus 5.5 --- .../coop/config/config_registry_rows_dev.inc | 3 + src/votv-coop/include/coop/dev/l10n_drill.h | 27 ++++ src/votv-coop/include/ui/dev_menu.h | 5 + src/votv-coop/sources.cmake | 1 + src/votv-coop/src/coop/dev/dev_lanes.cpp | 3 + src/votv-coop/src/coop/dev/l10n_drill.cpp | 148 ++++++++++++++++++ src/votv-coop/src/ui/dev_menu.cpp | 75 ++++++--- 7 files changed, 239 insertions(+), 23 deletions(-) create mode 100644 src/votv-coop/include/coop/dev/l10n_drill.h create mode 100644 src/votv-coop/src/coop/dev/l10n_drill.cpp diff --git a/src/votv-coop/include/coop/config/config_registry_rows_dev.inc b/src/votv-coop/include/coop/config/config_registry_rows_dev.inc index ddddf33fb..72aac8971 100644 --- a/src/votv-coop/include/coop/config/config_registry_rows_dev.inc +++ b/src/votv-coop/include/coop/config/config_registry_rows_dev.inc @@ -193,6 +193,9 @@ CFG_ENUM(chat_drill, "chat_drill", "dev", "off", "off|history|seed|i18n|span", " "Developer: chat checks judged in memory -- history (the host), seed (the second joiner), i18n " "(every peer), span (every peer: a mid-line nick's span through the feed); each prints " "[CHAT-DRILL] PASS, FAIL or ABORT.") +CFG_ENUM(l10n_drill, "l10n_drill", "dev", "off", "off|translated|english", "VOTVCOOP_L10N_DRILL", + "Developer: the interface-language drill on each peer -- opens two F1 panes, waits for the join line, " + "then expects them translated or English; prints [L10N-DRILL] DONE or FAIL.") CFG_FLAG(chat_span_prefix, "chat_span_prefix", "dev", false, "VOTVCOOP_CHAT_SPAN_PREFIX", "Developer: keep a peer action's nick as a prefix wherever its sentence put it (the chat drill's span red).") CFG_FLAG(chat_drill_bad_nick, "chat_drill_bad_nick", "dev", false, "VOTVCOOP_CHAT_DRILL_BAD_NICK", diff --git a/src/votv-coop/include/coop/dev/l10n_drill.h b/src/votv-coop/include/coop/dev/l10n_drill.h new file mode 100644 index 000000000..cb68e82d4 --- /dev/null +++ b/src/votv-coop/include/coop/dev/l10n_drill.h @@ -0,0 +1,27 @@ +// coop/dev/l10n_drill.h -- drill: the interface's language, judged on each peer (ini l10n_drill= +// off|translated|english / env VOTVCOOP_L10N_DRILL). +// +// Once this peer's world is up (the host: the client's), it opens the F1 menu on World > Rules and then +// Network > Stats, each time waiting until the render thread reports it DREW that pane, closes the menu, +// and waits until the chat feed holds the join line naming the other peer, in English or translated. +// Then it judges: the two subs' names as the tree draws them by whether their lookups found a +// translation (l10n::WasFound), the join line by which of its two forms the feed holds. `translated` +// passes when all three are translated; `english` when the interface is English and none is -- so the +// per-peer arm (an English host, a translated client) has a pass on both peers, and `translated` under +// an English language is the red. Lines: "[L10N-DRILL] DONE expect= +// locale= found=/ distinct=" or "[L10N-DRILL] FAIL expect= ...". Readiness throughout, +// never a clock: a run that never gets there ends on the rig's budget with no line. + +#pragma once + +namespace coop::net { class Session; } + +namespace coop::dev::l10n_drill { + +// Advance this peer's steps. Every pump tick in a world; a latched read when off. Game thread. +void Tick(coop::net::Session* session); + +// The session ended: the drill starts over and closes a menu it opened. +void OnDisconnect(); + +} // namespace coop::dev::l10n_drill diff --git a/src/votv-coop/include/ui/dev_menu.h b/src/votv-coop/include/ui/dev_menu.h index c6c9ef6eb..ea1d09f1a 100644 --- a/src/votv-coop/include/ui/dev_menu.h +++ b/src/votv-coop/include/ui/dev_menu.h @@ -31,4 +31,9 @@ bool DevMode(); // harness, which cannot click: the next Render selects it, if this peer may see it. Any thread. void RequestSelect(const char* category, const char* sub); +// Whether the pane the render thread last DREW is this one, named as the tree names it in English. The +// readiness a drill waits on after RequestSelect: true once a frame drew that pane, its text looked up. +// Any thread. +bool DrawnSelection(const char* category, const char* sub); + } // namespace ui::dev_menu diff --git a/src/votv-coop/sources.cmake b/src/votv-coop/sources.cmake index 259a864cd..f2bb1d249 100644 --- a/src/votv-coop/sources.cmake +++ b/src/votv-coop/sources.cmake @@ -520,6 +520,7 @@ set(VOTVCOOP_SOURCES src/coop/dev/stats_probe.cpp src/coop/dev/stat_order_drill.cpp src/coop/dev/chat_drill.cpp + src/coop/dev/l10n_drill.cpp src/coop/dev/ban_drill.cpp src/coop/dev/bug_report_drill.cpp src/coop/dev/grants_drill.cpp diff --git a/src/votv-coop/src/coop/dev/dev_lanes.cpp b/src/votv-coop/src/coop/dev/dev_lanes.cpp index 6c3bd373e..9e205e805 100644 --- a/src/votv-coop/src/coop/dev/dev_lanes.cpp +++ b/src/votv-coop/src/coop/dev/dev_lanes.cpp @@ -43,6 +43,7 @@ #include "coop/dev/stats_probe.h" // [dev] every player stat read, written and restored in one frame, and the game's status effects added, listed and removed #include "coop/dev/stat_order_drill.h" // [dev] the host orders and queries a client's stats and effects and checks every answer #include "coop/dev/chat_drill.h" // [dev] the chat checks, judged in memory +#include "coop/dev/l10n_drill.h" // [dev] the interface language, judged on each peer #include "coop/dev/ban_drill.h" // [dev] the host bans a joined client by the moderation verb #include "coop/dev/bug_report_drill.h" // [dev] a bug report is saved and its zip judged in memory #include "coop/dev/grants_drill.h" // [dev] this machine's local grants judged against an arm @@ -165,6 +166,7 @@ void EndSession() { coop::dev::stats_probe::OnDisconnect(); // [dev] a rejoin's peers run the probe again coop::dev::stat_order_drill::OnDisconnect(); // [dev] the steps start over, an answer still in flight is ignored coop::dev::chat_drill::OnDisconnect(); // [dev] the drill starts over, a chat it opened is closed + coop::dev::l10n_drill::OnDisconnect(); // [dev] the drill starts over, a menu it opened is closed coop::dev::ban_drill::OnSessionEnd(); // [dev] empty by design: its one state, the step, stands coop::dev::pause_quit_drill::OnDisconnect(); // [dev] a pending wait for the pause menu is dropped; the per-process latch stands coop::dev::order_selftest::OnDisconnect(); // [dev] a rejoin's peers run their legs again @@ -249,6 +251,7 @@ void TickDrills(coop::net::Session& session) { coop::dev::stats_probe::Tick(&session); // [dev] the player stats and status effects probe (a latched read when off) coop::dev::stat_order_drill::Tick(&session); // [dev] the stat order drill's steps (a latched read when off) coop::dev::chat_drill::Tick(&session); // [dev] the chat drill (a latched read when off) + coop::dev::l10n_drill::Tick(&session); // [dev] the l10n drill (a latched read when off) coop::dev::ban_drill::Tick(&session); // [dev] the host ban drill (a latched read when off) coop::dev::bug_report_drill::Tick(&session); // [dev] the bug-report drill (a latched read when off) coop::dev::grants_drill::Tick(&session); // [dev] the local grants drill (a latched read when off) diff --git a/src/votv-coop/src/coop/dev/l10n_drill.cpp b/src/votv-coop/src/coop/dev/l10n_drill.cpp new file mode 100644 index 000000000..d2f85200e --- /dev/null +++ b/src/votv-coop/src/coop/dev/l10n_drill.cpp @@ -0,0 +1,148 @@ +// coop/dev/l10n_drill.cpp -- see coop/dev/l10n_drill.h. + +#include "coop/dev/l10n_drill.h" + +#include "coop/comms/chat_feed.h" +#include "coop/config/config.h" +#include "coop/config/config_registry.h" +#include "coop/net/session.h" +#include "coop/session/net_pump.h" // HasAnnouncedWorldReady +#include "coop/session/player_handshake.h" +#include "coop/text/utf8_codec.h" +#include "l10n/l10n.h" +#include "ui/dev_menu.h" +#include "ui/imgui_overlay.h" + +#include "ue_wrap/core/log.h" + +#include +#include +#include + +namespace coop::dev::l10n_drill { +namespace { + +enum class Expect : uint8_t { Off, Translated, English }; + +Expect ExpectNow() { + static const Expect e = [] { + const std::string v = coop::config::ResolveEnum(::coop::config_registry::rows::l10n_drill); + return v == "translated" ? Expect::Translated : v == "english" ? Expect::English : Expect::Off; + }(); + return e; +} + +const char* ExpectName() { return ExpectNow() == Expect::Translated ? "translated" : "english"; } + +enum class Step : uint8_t { WaitWorld, OpenRules, WaitRules, OpenStats, WaitStats, WaitJoinLine, Done }; +Step g_step = Step::WaitWorld; +bool g_menuOpened = false; + +// The join line each role composes, as player_handshake looks it up: the host's names the client +// that joined, the client's the host whose game it joined. +constexpr const char* kHostJoinLine = "%1$s joined the game"; +constexpr const char* kClientJoinLine = "Joined %1$s's game"; + +void CloseMenu() { + if (!g_menuOpened) return; + ui::imgui_overlay::SetVisible(false); + g_menuOpened = false; +} + +// Which form of the join line the feed holds: the line is an event line, one colour, so a row's text +// is the whole line. Waiting for either exact form is the readiness (the "connecting" line names the +// same peer and must not count), and which one arrived is the verdict on the line's composition. +enum class Line : uint8_t { None, English, Translated }; +Line JoinLineInFeed(const char* msgid, const std::string& nick) { + char english[256], translated[256]; + if (nick.empty() || l10n::Fmt(english, sizeof(english), msgid, nick.c_str()) < 0) return Line::None; + const char* tr = l10n::T(msgid); + const bool differs = tr != msgid && l10n::Fmt(translated, sizeof(translated), tr, nick.c_str()) >= 0 && + std::strcmp(translated, english) != 0; + Line seen = Line::None; + coop::chat_feed::ForEachRow([&](const coop::chat_feed::RowView& r) { + if (differs && r.line == translated) seen = Line::Translated; + else if (seen == Line::None && r.line == english) seen = Line::English; + }); + return seen; +} + +void Judge(Line join) { + const char* probes[] = {"Rules", "Stats"}; + int found = 0; + const char* firstMissing = nullptr; + const char* firstFound = nullptr; + for (const char* p : probes) { + if (l10n::WasFound(nullptr, p)) { + ++found; + if (!firstFound) firstFound = p; + } else if (!firstMissing) { + firstMissing = p; + } + } + if (join == Line::Translated) ++found; + else if (!firstMissing) firstMissing = "the join line"; + if (join == Line::Translated && !firstFound) firstFound = "the join line"; + constexpr int kProbes = 3; + const char* locale = l10n::ActiveLocale(); + g_step = Step::Done; + if (ExpectNow() == Expect::Translated && found != kProbes) { + UE_LOGW("[L10N-DRILL] FAIL expect=translated locale=%s missing=%s", locale[0] ? locale : "-", firstMissing); + return; + } + if (ExpectNow() == Expect::English && (locale[0] || found != 0)) { + UE_LOGW("[L10N-DRILL] FAIL expect=english locale=%s found=%s", locale[0] ? locale : "-", + firstFound ? firstFound : "-"); + return; + } + UE_LOGI("[L10N-DRILL] DONE expect=%s locale=%s found=%d/%d distinct=%zu", ExpectName(), locale[0] ? locale : "-", + found, kProbes, l10n::FoundCount()); +} + +} // namespace + +void Tick(coop::net::Session* session) { + if (ExpectNow() == Expect::Off || g_step == Step::Done || !session || !session->running()) return; + const bool isHost = session->role() == coop::net::Role::Host; + switch (g_step) { + case Step::WaitWorld: + if (isHost ? !session->IsSlotWorldReady(1) : !coop::net_pump::HasAnnouncedWorldReady()) return; + ui::imgui_overlay::SetVisible(true); + g_menuOpened = true; + g_step = Step::OpenRules; + return; + case Step::OpenRules: + ui::dev_menu::RequestSelect("World", "Rules"); + g_step = Step::WaitRules; + return; + case Step::WaitRules: + if (!ui::dev_menu::DrawnSelection("World", "Rules")) return; + g_step = Step::OpenStats; + return; + case Step::OpenStats: + ui::dev_menu::RequestSelect("Network", "Stats"); + g_step = Step::WaitStats; + return; + case Step::WaitStats: + if (!ui::dev_menu::DrawnSelection("Network", "Stats")) return; + CloseMenu(); + g_step = Step::WaitJoinLine; + return; + case Step::WaitJoinLine: { + const std::wstring other = coop::player_handshake::NicknameForSlot(isHost ? 1 : 0); + const Line join = JoinLineInFeed(isHost ? kHostJoinLine : kClientJoinLine, coop::text::ToUtf8(other)); + if (join == Line::None) return; + Judge(join); + return; + } + case Step::Done: + return; + } +} + +void OnDisconnect() { + CloseMenu(); + g_step = Step::WaitWorld; +} + +} // namespace coop::dev::l10n_drill diff --git a/src/votv-coop/src/ui/dev_menu.cpp b/src/votv-coop/src/ui/dev_menu.cpp index 63587fdbc..a29319d07 100644 --- a/src/votv-coop/src/ui/dev_menu.cpp +++ b/src/votv-coop/src/ui/dev_menu.cpp @@ -3,6 +3,8 @@ #include "ui/dev_menu.h" #include "ui/dev_panes.h" +#include "l10n/l10n.h" + #include "coop/dev/dev_gate.h" #include "coop/permissions/grants_core.h" #include "coop/session/local_grants.h" @@ -199,6 +201,9 @@ void RenderAdminPlayers() { ui::admin_panel::Render(); } void RenderWorldRules() { ui::world_rules_panel::Render(); } // ---- the strict nested taxonomy (refined as features land) ------------------- +// A player entry's name is marked for translation and shown translated (the drawer's Name below); a +// developer entry's name stays English. Either way the name itself is the selection key RequestSelect +// compares and the widget's id, so neither ever depends on the language. // Organized by the GAME'S OWN DOMAINS: Player (the person) ; World (the simulation // state: rules/weather/clock/economy) ; Content (the game's spawnable/triggerable // content: entities + events) ; Network ; Administration ; Cosmetics ; Report a bug @@ -211,11 +216,11 @@ const std::vector& Tree() { { "Vitals", { { &ui::dev_panes::RenderRestoreVitals, true } }, true }, { "HUD", { { &ui::dev_panes::RenderPosHud, true }, { &ui::dev_panes::RenderObjectOverlay, true }, { &ui::dev_panes::RenderRagdollBones, true } }, true }, }, true }, - { "World", { + { L10N_MARK("World"), { // Rules is for EVERYONE (host+clients+solo): a read-only view of the // world rules this peer runs under (mainGameInstance.gameRules) + // gamemode. Non-dev, non-host. - { "Rules", { { &RenderWorldRules, false } }, false }, + { L10N_MARK("Rules"), { { &RenderWorldRules, false } }, false }, { "Weather", { { &ui::dev_panes::RenderSnow, true } }, true }, { "Clock", { { &ui::dev_panes::RenderSetClock, true } }, true }, { "Economy", { { &ui::dev_panes::RenderGivePoints, true } }, true }, @@ -229,25 +234,25 @@ const std::vector& Tree() { { "Props", { { &ui::dev_panes::RenderSpawnMenuUnlock, true } }, true }, { "Events", { { &ui::dev_panes::RenderEvents, true } }, true }, }, true }, - { "Network", { + { L10N_MARK("Network"), { // Stats is for EVERYONE (the overlay toggle + live session readout); // Session stays a dev placeholder, hidden for regular players. - { "Stats", { { &RenderNetStats, false } }, false }, + { L10N_MARK("Stats"), { { &RenderNetStats, false } }, false }, { "Session", {}, true }, }, false }, - { "Administration", { + { L10N_MARK("Administration"), { // HOST-role-gated (not dev): online/offline/banned player admin. - { "Players", { { &RenderAdminPlayers, false } }, false, true }, - { "Server settings", { { &ui::server_settings_pane::Render, false } }, false, true }, + { L10N_MARK("Players"), { { &RenderAdminPlayers, false } }, false, true }, + { L10N_MARK("Server settings"), { { &ui::server_settings_pane::Render, false } }, false, true }, }, false, true }, - { "Cosmetics", { - { "Skins", { { &RenderSkins, false } }, false }, - { "Nameplate", { { &RenderNameplatePref, false } }, false }, - { "Chat", { { &RenderChatPref, false } }, false }, - { "Interface", { { &RenderFontPref, false } }, false }, + { L10N_MARK("Cosmetics"), { + { L10N_MARK("Skins"), { { &RenderSkins, false } }, false }, + { L10N_MARK("Nameplate"), { { &RenderNameplatePref, false } }, false }, + { L10N_MARK("Chat"), { { &RenderChatPref, false } }, false }, + { L10N_MARK("Interface"), { { &RenderFontPref, false } }, false }, }, false }, - { "Report a bug", { - { "Report", { { &ui::bug_report_pane::Render, false } }, false }, + { L10N_MARK("Report a bug"), { + { L10N_MARK("Report"), { { &ui::bug_report_pane::Render, false } }, false }, }, false }, }; return kTree; @@ -260,6 +265,13 @@ const Sub* g_selSub = nullptr; // the flag, and read only after it is seen set. std::string g_wantCat, g_wantSub; std::atomic g_wantSelect{false}; +// The pane the render thread last drew, as ONE value -- (category index + 1) << 16 | (sub index + 1), +// 0 before any -- stored after the pane's draw, so a reader on another thread that sees it knows that +// frame's text was looked up. +std::atomic g_drawn{0}; + +// What the tree shows for an entry: a player entry translated, a developer entry as it is. +const char* Name(const char* name, bool dev) { return dev ? name : l10n::T(name); } // Dev switch, read ONCE at boot by Init() (off the render thread). devMode=false // -> only non-dev (Cosmetics) shows; the menu itself is always available. @@ -282,6 +294,15 @@ bool DevMode() { (::coop::dev_gate::Allowed() || ::coop::session::local_grants::AnyDevLocal()); } +bool DrawnSelection(const char* category, const char* sub) { + const uint32_t v = g_drawn.load(std::memory_order_acquire); + if (v == 0 || !category || !sub) return false; + const auto& tree = Tree(); + const uint32_t c = (v >> 16) - 1, s = (v & 0xFFFF) - 1; + return c < tree.size() && s < tree[c].subs.size() && std::strcmp(tree[c].name, category) == 0 && + std::strcmp(tree[c].subs[s].name, sub) == 0; +} + void RequestSelect(const char* category, const char* sub) { if (!category || !sub || g_wantSelect.load(std::memory_order_acquire)) return; g_wantCat = category; @@ -338,7 +359,7 @@ void Render() { ImGui::PushStyleColor(ImGuiCol_HeaderHovered, ImVec4(0.26f, 0.30f, 0.38f, 1.00f)); ImGui::PushStyleColor(ImGuiCol_HeaderActive, ImVec4(0.30f, 0.35f, 0.45f, 1.00f)); ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.55f, 0.82f, 1.00f, 1.00f)); - const bool open = ImGui::TreeNodeEx(cat.name, + const bool open = ImGui::TreeNodeEx(l10n::Label(Name(cat.name, cat.dev), cat.name), ImGuiTreeNodeFlags_Framed | ImGuiTreeNodeFlags_DefaultOpen | ImGuiTreeNodeFlags_SpanAvailWidth | ImGuiTreeNodeFlags_NoTreePushOnOpen); ImGui::PopStyleColor(4); @@ -348,7 +369,10 @@ void Render() { if (sub.dev && !devMode) continue; if (sub.host && !isHost) continue; const bool selected = (g_selSub == &sub); - if (ImGui::Selectable(sub.name, selected)) { g_selCat = &cat; g_selSub = ⊂ } + if (ImGui::Selectable(l10n::Label(Name(sub.name, sub.dev), sub.name), selected)) { + g_selCat = &cat; + g_selSub = ⊂ + } } ImGui::Unindent(S(14.0f)); } @@ -359,31 +383,36 @@ void Render() { // Right: the selected subcategory's controls. ImGui::BeginChild("##content", ImVec2(0, 0), ImGuiChildFlags_Borders); if (g_selCat && g_selSub) { - ImGui::TextDisabled("%s > %s", g_selCat->name, g_selSub->name); + ImGui::TextDisabled("%s > %s", Name(g_selCat->name, g_selCat->dev), Name(g_selSub->name, g_selSub->dev)); ImGui::Separator(); int shown = 0; for (const auto& it : g_selSub->items) { if (it.dev && !devMode) continue; if (it.render) { it.render(); ++shown; } } - if (shown == 0) ImGui::TextDisabled("No tools here yet -- coming soon."); + if (shown == 0) ImGui::TextDisabled("%s", l10n::T("No tools here yet -- coming soon.")); + const uint32_t cat = static_cast(g_selCat - tree.data()); + const uint32_t sub = static_cast(g_selSub - g_selCat->subs.data()); + g_drawn.store(((cat + 1) << 16) | (sub + 1), std::memory_order_release); } else { - ImGui::TextDisabled("Select a category on the left."); + ImGui::TextDisabled("%s", l10n::T("Select a category on the left.")); // Which graphics API this session is running on (the overlay knows // because it renders through it). Answers "why does X look/behave // different for me" without asking the user to dig through logs. { const char* rhi = ui::overlay_backend::Kind(); + char line[128]; + l10n::Fmt(line, sizeof(line), l10n::T("Graphics API: %s"), rhi ? rhi : l10n::T("starting up")); ImGui::Spacing(); - ImGui::TextDisabled("Graphics API: %s", rhi ? rhi : "starting up"); + ImGui::TextDisabled("%s", line); } if (!devMode) { ImGui::Spacing(); if (g_devMode && !::coop::dev_gate::Allowed()) - ImGui::TextWrapped("As a client you see only what the host allows you."); + ImGui::TextWrapped("%s", l10n::T("As a client you see only what the host allows you.")); else - ImGui::TextWrapped("Developer tools are hidden. Set [dev] devkeys=1 in " - "multivoid.ini to show them."); + ImGui::TextWrapped("%s", l10n::T("Developer tools are hidden. Set [dev] devkeys=1 in " + "multivoid.ini to show them.")); } } ImGui::EndChild(); From 357099be8a53c0cd1854af11af719b6f7e7df9d7 Mon Sep 17 00:00:00 2001 From: pelmentor Date: Sat, 10 Oct 2026 06:19:44 +0600 Subject: [PATCH 082/306] [ui] the F1 player panes in the player's language Cosmetics (Chat, Nameplate, Interface, Skins), Administration > Players, World > Rules, Network > Stats and the stats overlay, and Report a bug look every drawn string up through l10n. A sentence with values is one msgid through Fmt, a label on an id-bearing widget goes through Label with a fixed id, a lone word on a button takes the f1 context, and the peer count of the overlay is a plural pair. Left as they are: the game's own rule names, categories and enum words, the bug-report validator's and bundle's sentences, unit suffixes, link labels, the font role names, "--", "(?)" and the file names listed. Co-Authored-By: Claude Opus 5.5 --- src/votv-coop/src/ui/admin_panel.cpp | 117 ++++++++++++--------- src/votv-coop/src/ui/bug_report_pane.cpp | 41 +++++--- src/votv-coop/src/ui/dev_menu.cpp | 42 ++++---- src/votv-coop/src/ui/net_stats_panel.cpp | 52 +++++---- src/votv-coop/src/ui/skins_panel.cpp | 31 +++--- src/votv-coop/src/ui/world_rules_panel.cpp | 36 ++++--- 6 files changed, 184 insertions(+), 135 deletions(-) diff --git a/src/votv-coop/src/ui/admin_panel.cpp b/src/votv-coop/src/ui/admin_panel.cpp index ed1b1689b..7e8e872f6 100644 --- a/src/votv-coop/src/ui/admin_panel.cpp +++ b/src/votv-coop/src/ui/admin_panel.cpp @@ -2,6 +2,8 @@ #include "ui/admin_panel.h" +#include "l10n/l10n.h" + #include "coop/commands/command_sync.h" #include "coop/player/roster.h" #include "coop/moderation/ban_list.h" @@ -94,7 +96,10 @@ void OpenBanFor(bool online, const char* id, const char* nick) { // A player's id on hover over its nick: how the host finds the name a permission file is stored // under. void IdTooltip(const char* id) { - if (id[0] != '\0' && ImGui::IsItemHovered()) ImGui::SetTooltip("Player id %s", id); + if (id[0] == '\0' || !ImGui::IsItemHovered()) return; + char tip[256]; + l10n::Fmt(tip, sizeof(tip), l10n::T("Player id %s"), id); + ImGui::SetTooltip("%s", tip); } // A small button that is off, with the reason on hover, while the person has no proved id yet. @@ -103,19 +108,19 @@ bool IdButton(const char* label, bool hasId) { const bool clicked = ImGui::SmallButton(label); if (!hasId) ImGui::EndDisabled(); if (!hasId && ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) - ImGui::SetTooltip("Their identity is not proved yet."); + ImGui::SetTooltip("%s", l10n::T("Their identity is not proved yet.")); return clicked; } void RenderOnlineSection(const coop::roster::Snapshot& rs) { - SectionHeader("Online"); + SectionHeader(l10n::T("Online")); int shown = 0; if (ImGui::BeginTable("##admin_online", 4, ImGuiTableFlags_RowBg | ImGuiTableFlags_PadOuterX)) { - ImGui::TableSetupColumn("Player", ImGuiTableColumnFlags_WidthStretch); - ImGui::TableSetupColumn("Link", ImGuiTableColumnFlags_WidthFixed, S(72.f)); - ImGui::TableSetupColumn("Ping", ImGuiTableColumnFlags_WidthFixed, S(52.f)); - ImGui::TableSetupColumn("Actions", ImGuiTableColumnFlags_WidthFixed, S(220.f)); + ImGui::TableSetupColumn(l10n::Label(l10n::T("Player"), "player"), ImGuiTableColumnFlags_WidthStretch); + ImGui::TableSetupColumn(l10n::Label(l10n::T("Link"), "link"), ImGuiTableColumnFlags_WidthFixed, S(72.f)); + ImGui::TableSetupColumn(l10n::Label(l10n::T("Ping"), "ping"), ImGuiTableColumnFlags_WidthFixed, S(52.f)); + ImGui::TableSetupColumn(l10n::Label(l10n::T("Actions"), "actions"), ImGuiTableColumnFlags_WidthFixed, S(220.f)); ImGui::TableHeadersRow(); for (int i = 0; i < rs.count; ++i) { const coop::roster::Row& r = rs.rows[i]; @@ -125,11 +130,11 @@ void RenderOnlineSection(const coop::roster::Snapshot& rs) { ImGui::PushID(r.slot); ImGui::TableSetColumnIndex(0); ImGui::AlignTextToFramePadding(); - ImGui::TextUnformatted(r.nick[0] ? r.nick : "Remote player"); + ImGui::TextUnformatted(r.nick[0] ? r.nick : l10n::T("Remote player")); IdTooltip(r.playerId); if (r.otherBuild) { ImGui::SameLine(0.0f, S(6.0f)); - ImGui::TextColored(ImVec4(1.00f, 0.82f, 0.35f, 0.85f), "other build"); + ImGui::TextColored(ImVec4(1.00f, 0.82f, 0.35f, 0.85f), "%s", l10n::T("other build")); } // Same renderer as the scoreboard (ui::link_format) -- this panel // used to carry its own copy of the cascade, so the vocabulary could @@ -146,29 +151,29 @@ void RenderOnlineSection(const coop::roster::Snapshot& rs) { // Each button submits a command line for the person's proved id, not their slot -- // see g_banId. Without a proved id the buttons are off. const bool hasId = r.playerId[0] != '\0'; - if (IdButton("Teleport", hasId)) + if (IdButton(l10n::Label(l10n::Tc("f1", "Teleport"), "teleport"), hasId)) coop::command_sync::Submit(std::string("tphere ") + r.playerId); ImGui::SameLine(); - if (IdButton("Kick", hasId)) + if (IdButton(l10n::Label(l10n::Tc("f1", "Kick"), "kick"), hasId)) coop::command_sync::Submit(std::string("kick ") + r.playerId); ImGui::SameLine(); ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.00f, 0.45f, 0.42f, 1.0f)); - if (IdButton("Ban...", hasId)) OpenBanFor(true, r.playerId, r.nick); + if (IdButton(l10n::Label(l10n::Tc("f1", "Ban..."), "ban_"), hasId)) OpenBanFor(true, r.playerId, r.nick); ImGui::PopStyleColor(); ImGui::PopID(); } ImGui::EndTable(); } - if (shown == 0) ImGui::TextDisabled("No remote players connected."); + if (shown == 0) ImGui::TextDisabled("%s", l10n::T("No remote players connected.")); } void RenderOfflineSection() { - SectionHeader("Offline (seen before)"); + SectionHeader(l10n::T("Offline (seen before)")); if (ImGui::BeginTable("##admin_offline", 3, ImGuiTableFlags_RowBg | ImGuiTableFlags_PadOuterX)) { - ImGui::TableSetupColumn("Player", ImGuiTableColumnFlags_WidthStretch); - ImGui::TableSetupColumn("Last seen", ImGuiTableColumnFlags_WidthFixed, S(130.f)); - ImGui::TableSetupColumn("Actions", ImGuiTableColumnFlags_WidthFixed, S(90.f)); + ImGui::TableSetupColumn(l10n::Label(l10n::T("Player"), "player"), ImGuiTableColumnFlags_WidthStretch); + ImGui::TableSetupColumn(l10n::Label(l10n::T("Last seen"), "last_seen"), ImGuiTableColumnFlags_WidthFixed, S(130.f)); + ImGui::TableSetupColumn(l10n::Label(l10n::T("Actions"), "actions"), ImGuiTableColumnFlags_WidthFixed, S(90.f)); ImGui::TableHeadersRow(); // Only the rows in view are drawn: the registry grows with every player who ever joined. ImGuiListClipper clipper; @@ -180,7 +185,7 @@ void RenderOfflineSection() { ImGui::PushID(e.guid); ImGui::TableSetColumnIndex(0); ImGui::AlignTextToFramePadding(); - ImGui::TextUnformatted(e.nick[0] ? e.nick : "(no nick)"); + ImGui::TextUnformatted(e.nick[0] ? e.nick : l10n::T("(no nick)")); IdTooltip(e.guid); ImGui::TableSetColumnIndex(1); char when[24]; @@ -189,10 +194,10 @@ void RenderOfflineSection() { ImGui::TableSetColumnIndex(2); const bool alreadyBanned = g_banIds.count(std::string_view(e.guid)) != 0; if (alreadyBanned) { - ImGui::TextDisabled("banned"); + ImGui::TextDisabled("%s", l10n::T("banned")); } else { ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.00f, 0.45f, 0.42f, 1.0f)); - if (ImGui::SmallButton("Ban...")) OpenBanFor(false, e.guid, e.nick); + if (ImGui::SmallButton(l10n::Label(l10n::Tc("f1", "Ban..."), "ban_"))) OpenBanFor(false, e.guid, e.nick); ImGui::PopStyleColor(); } ImGui::PopID(); @@ -200,21 +205,21 @@ void RenderOfflineSection() { } ImGui::EndTable(); } - if (g_seen.empty()) ImGui::TextDisabled("Nobody in the registry yet (players are recorded when they join)."); - else if (g_offline.empty()) ImGui::TextDisabled("Everyone in the registry is online."); + if (g_seen.empty()) ImGui::TextDisabled("%s", l10n::T("Nobody in the registry yet (players are recorded when they join).")); + else if (g_offline.empty()) ImGui::TextDisabled("%s", l10n::T("Everyone in the registry is online.")); } void RenderBannedSection() { - SectionHeader("Banned"); + SectionHeader(l10n::T("Banned")); if (coop::ban_list::IsReadOnly()) - ImGui::TextDisabled("The ban file has entries this version cannot read, so bans made now last until the " - "server stops. See the log."); + ImGui::TextDisabled("%s", l10n::T("The ban file has entries this version cannot read, so bans made now last until the " + "server stops. See the log.")); if (ImGui::BeginTable("##admin_banned", 5, ImGuiTableFlags_RowBg | ImGuiTableFlags_PadOuterX)) { - ImGui::TableSetupColumn("Player", ImGuiTableColumnFlags_WidthStretch); - ImGui::TableSetupColumn("ID", ImGuiTableColumnFlags_WidthFixed, S(110.f)); - ImGui::TableSetupColumn("When", ImGuiTableColumnFlags_WidthFixed, S(130.f)); - ImGui::TableSetupColumn("Reason", ImGuiTableColumnFlags_WidthStretch); + ImGui::TableSetupColumn(l10n::Label(l10n::T("Player"), "player"), ImGuiTableColumnFlags_WidthStretch); + ImGui::TableSetupColumn(l10n::Label(l10n::T("ID"), "id"), ImGuiTableColumnFlags_WidthFixed, S(110.f)); + ImGui::TableSetupColumn(l10n::Label(l10n::T("When"), "when"), ImGuiTableColumnFlags_WidthFixed, S(130.f)); + ImGui::TableSetupColumn(l10n::Label(l10n::T("Reason"), "reason"), ImGuiTableColumnFlags_WidthStretch); ImGui::TableSetupColumn("##act", ImGuiTableColumnFlags_WidthFixed, S(70.f)); ImGui::TableHeadersRow(); // Only the rows in view are drawn: a client granted the offline ban can grow this list. @@ -227,11 +232,15 @@ void RenderBannedSection() { ImGui::PushID(b.id); ImGui::TableSetColumnIndex(0); ImGui::AlignTextToFramePadding(); - ImGui::TextUnformatted(b.nick[0] ? b.nick : "(no nick)"); + ImGui::TextUnformatted(b.nick[0] ? b.nick : l10n::T("(no nick)")); ImGui::TableSetColumnIndex(1); ImGui::TextDisabled("%.8s", b.id); - if (ImGui::IsItemHovered()) - ImGui::SetTooltip("Player id %s\nAddress %s", b.id, b.address[0] ? b.address : "not blocked"); + if (ImGui::IsItemHovered()) { + char tip[256]; + l10n::Fmt(tip, sizeof(tip), l10n::T("Player id %1$s\nAddress %2$s"), b.id, + b.address[0] ? b.address : l10n::T("not blocked")); + ImGui::SetTooltip("%s", tip); + } ImGui::TableSetColumnIndex(2); char when[24]; FormatUnix(b.bannedUnix, when, sizeof(when)); @@ -239,7 +248,7 @@ void RenderBannedSection() { ImGui::TableSetColumnIndex(3); ImGui::TextDisabled("%s", b.reason[0] ? b.reason : "--"); ImGui::TableSetColumnIndex(4); - if (ImGui::SmallButton("Unban")) { + if (ImGui::SmallButton(l10n::Label(l10n::Tc("f1", "Unban"), "unban"))) { coop::command_sync::Submit(std::string("unban ") + b.id); g_lastRefresh = -1.0; // the row drops at the next refresh after the posted command ran } @@ -248,34 +257,40 @@ void RenderBannedSection() { } ImGui::EndTable(); } - if (g_bans.empty()) ImGui::TextDisabled("No banned players."); + if (g_bans.empty()) ImGui::TextDisabled("%s", l10n::T("No banned players.")); } void RenderBanModal() { const bool pending = g_banId[0] != '\0'; - if (pending && !ImGui::IsPopupOpen("Ban player##admin")) - ImGui::OpenPopup("Ban player##admin"); + // One title for the open, the test and the modal: the text is translated, the id never is. + const l10n::Label title(l10n::T("Ban player"), "admin"); + if (pending && !ImGui::IsPopupOpen(title)) + ImGui::OpenPopup(title); const ImGuiIO& io = ImGui::GetIO(); ImGui::SetNextWindowPos(ImVec2(io.DisplaySize.x * 0.5f, io.DisplaySize.y * 0.5f), ImGuiCond_Always, ImVec2(0.5f, 0.5f)); - if (ImGui::BeginPopupModal("Ban player##admin", nullptr, + if (ImGui::BeginPopupModal(title, nullptr, ImGuiWindowFlags_AlwaysAutoResize)) { - ImGui::Text("Permanently ban %s?", g_banNick[0] ? g_banNick : "this player"); - ImGui::TextDisabled(g_banOnline - ? "Disconnected now and refused whenever they rejoin." - : "Refused whenever they rejoin."); + char heading[256]; + l10n::Fmt(heading, sizeof(heading), l10n::T("Permanently ban %s?"), + g_banNick[0] ? g_banNick : l10n::T("this player")); + ImGui::TextUnformatted(heading); + ImGui::TextDisabled("%s", g_banOnline + ? l10n::T("Disconnected now and refused whenever they rejoin.") + : l10n::T("Refused whenever they rejoin.")); ImGui::Spacing(); ImGui::SetNextItemWidth(S(320.f)); - ImGui::InputTextWithHint("##banreason", "reason (shown in the Banned list)", + ImGui::InputTextWithHint("##banreason", l10n::T("reason (shown in the Banned list)"), g_banReason, sizeof(g_banReason)); - ImGui::Checkbox("Also refuse their address", &g_banByAddress); + ImGui::Checkbox(l10n::Label(l10n::T("Also refuse their address"), "also_refuse_their_address"), + &g_banByAddress); if (ImGui::IsItemHovered()) - ImGui::SetTooltip("Also refuses anyone connecting from the same address -- people who share " - "their router or their provider's address are refused too. It only works on " - "a direct connection: a player who comes through a relay is refused by their " - "identity alone."); + ImGui::SetTooltip("%s", l10n::T("Also refuses anyone connecting from the same address -- people who share " + "their router or their provider's address are refused too. It only works on " + "a direct connection: a player who comes through a relay is refused by their " + "identity alone.")); ImGui::Spacing(); - if (ImGui::Button("Ban", ImVec2(S(110.f), 0))) { + if (ImGui::Button(l10n::Label(l10n::Tc("f1", "Ban"), "ban"), ImVec2(S(110.f), 0))) { // The reason is the raw remainder of the line, never quoted; none typed, the ban // command stores "banned by host". std::string line = std::string(g_banByAddress ? "ban " : "banid ") + g_banId; @@ -286,7 +301,7 @@ void RenderBanModal() { ImGui::CloseCurrentPopup(); } ImGui::SameLine(); - if (ImGui::Button("Cancel", ImVec2(S(110.f), 0))) { + if (ImGui::Button(l10n::Label(l10n::Tc("f1", "Cancel"), "cancel"), ImVec2(S(110.f), 0))) { g_banId[0] = '\0'; ImGui::CloseCurrentPopup(); } @@ -302,8 +317,8 @@ void Render() { coop::roster::Snapshot rs; coop::roster::GetSnapshot(rs); - ImGui::TextDisabled("Host administration. Bans follow the player and survive restarts;"); - ImGui::TextDisabled("the registry remembers every player who ever joined this host."); + ImGui::TextDisabled("%s", l10n::T("Host administration. Bans follow the player and survive restarts;\n" + "the registry remembers every player who ever joined this host.")); RenderOnlineSection(rs); RenderOfflineSection(); diff --git a/src/votv-coop/src/ui/bug_report_pane.cpp b/src/votv-coop/src/ui/bug_report_pane.cpp index a5d07d6c6..0233b4bce 100644 --- a/src/votv-coop/src/ui/bug_report_pane.cpp +++ b/src/votv-coop/src/ui/bug_report_pane.cpp @@ -8,6 +8,8 @@ #include "ui/bug_report_pane.h" +#include "l10n/l10n.h" + #include "coop/bug_report/report_bundle.h" #include "coop/bug_report/report_core.h" #include "coop/text/utf8_codec.h" @@ -53,20 +55,29 @@ void DrawField(const char* label, const char* id, char* buf, size_t size) { } void DrawFiles() { - ImGui::TextUnformatted("Files that will be included"); + ImGui::TextUnformatted(l10n::T("Files that will be included")); ImGui::Indent(S(12.0f)); + char line[256]; for (const BR::Entry& e : g_entries) { if (!e.leftOut.empty()) { - ImGui::TextDisabled("%s -- left out: %s", e.name, e.leftOut.c_str()); + l10n::Fmt(line, sizeof(line), l10n::T("%1$s -- left out: %2$s"), e.name, e.leftOut.c_str()); + ImGui::TextDisabled("%s", line); } else if (e.tailOnly && e.bytesOnDisk > BR::kUe4ssTailBytes) { - ImGui::Text("%s (last %llu KB of %llu KB)", e.name, Kb(BR::kUe4ssTailBytes), Kb(e.bytesOnDisk)); + l10n::Fmt(line, sizeof(line), l10n::T("%1$s (last %2$llu KB of %3$llu KB)"), e.name, + Kb(BR::kUe4ssTailBytes), Kb(e.bytesOnDisk)); + ImGui::TextUnformatted(line); } else { - ImGui::Text("%s (%llu KB)", e.name, Kb(e.bytesOnDisk)); + l10n::Fmt(line, sizeof(line), l10n::T("%1$s (%2$llu KB)"), e.name, Kb(e.bytesOnDisk)); + ImGui::TextUnformatted(line); } } for (const char* name : BR::kMadeEntries) { - if (std::strcmp(name, "multivoid.ini") == 0) ImGui::Text("%s (without passwords)", name); - else ImGui::TextUnformatted(name); + if (std::strcmp(name, "multivoid.ini") == 0) { + l10n::Fmt(line, sizeof(line), l10n::T("%s (without passwords)"), name); + ImGui::TextUnformatted(line); + } else { + ImGui::TextUnformatted(name); + } } ImGui::Unindent(S(12.0f)); } @@ -90,12 +101,14 @@ void DrawStatus(const BR::Status& st) { case BR::Phase::Idle: break; case BR::Phase::Building: - ImGui::TextUnformatted("Saving the report..."); + ImGui::TextUnformatted(l10n::T("Saving the report...")); break; case BR::Phase::Done: { const std::string name = coop::text::ToUtf8(std::filesystem::path(st.zipPath).filename().wstring()); - ImGui::TextWrapped("Saved: %s (%llu KB)", name.c_str(), Kb(st.zipBytes)); - if (ImGui::Button("Show in folder")) ShowInFolder(st.zipPath); + char saved[256]; + l10n::Fmt(saved, sizeof(saved), l10n::T("Saved: %1$s (%2$llu KB)"), name.c_str(), Kb(st.zipBytes)); + ImGui::TextWrapped("%s", saved); + if (ImGui::Button(l10n::Label(l10n::T("Show in folder"), "show_in_folder"))) ShowInFolder(st.zipPath); break; } case BR::Phase::Failed: @@ -113,11 +126,11 @@ void Render() { if (g_lastFrame != frame - 1) g_entries = BR::ListEntries(); g_lastFrame = frame; - ImGui::TextWrapped("Saves a report file on this PC. Nothing is sent."); + ImGui::TextWrapped("%s", l10n::T("Saves a report file on this PC. Nothing is sent.")); ImGui::Spacing(); - DrawField("What happened", "##happened", g_happened, sizeof(g_happened)); - DrawField("What you expected", "##expected", g_expected, sizeof(g_expected)); - ImGui::TextUnformatted("Contact (optional, e.g. a Discord name)"); + DrawField(l10n::T("What happened"), "##happened", g_happened, sizeof(g_happened)); + DrawField(l10n::T("What you expected"), "##expected", g_expected, sizeof(g_expected)); + ImGui::TextUnformatted(l10n::T("Contact (optional, e.g. a Discord name)")); ImGui::SetNextItemWidth(-FLT_MIN); ImGui::InputText("##contact", g_contact, sizeof(g_contact)); ImGui::Spacing(); @@ -126,7 +139,7 @@ void Render() { const BR::Status st = BR::GetStatus(); ImGui::BeginDisabled(st.phase == BR::Phase::Building); - if (ImGui::Button("Save report")) OnSavePressed(); + if (ImGui::Button(l10n::Label(l10n::T("Save report"), "save_report"))) OnSavePressed(); ImGui::EndDisabled(); if (g_formError) { ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.00f, 0.45f, 0.42f, 1.0f)); diff --git a/src/votv-coop/src/ui/dev_menu.cpp b/src/votv-coop/src/ui/dev_menu.cpp index a29319d07..2da46be28 100644 --- a/src/votv-coop/src/ui/dev_menu.cpp +++ b/src/votv-coop/src/ui/dev_menu.cpp @@ -62,29 +62,29 @@ void RenderNetStats() { ui::net_stats_panel::RenderMenuPref(); } // coop::peer_action_feed. void RenderChatPref() { bool on = coop::peer_action_feed::Enabled(); - if (ImGui::Checkbox("Peer action notifications", &on)) + if (ImGui::Checkbox(l10n::Label(l10n::T("Peer action notifications"), "peer_action_notifications"), &on)) coop::config::SetValue(::coop::config_registry::rows::ui_chat_peer_actions, on ? "1" : "0"); - ImGui::TextDisabled("Show a chat line when another player does a shared action"); - ImGui::TextDisabled("(e.g. deletes an email). Local preference; persists across"); - ImGui::TextDisabled("sessions (ui.chat.peer_actions)."); + ImGui::TextDisabled("%s", l10n::T("Show a chat line when another player does a shared action\n" + "(e.g. deletes an email). Local preference; persists across\n" + "sessions (ui.chat.peer_actions).")); } // The local player's plate-visibility preference. SYNCED (a live NameplateChange plus the // Join prefs byte for late joiners) and persisted (multivoid.ini nameplate=). void RenderNameplatePref() { bool on = coop::nameplate::LocalVisible(); - if (ImGui::Checkbox("Show my nameplate to other players", &on)) + if (ImGui::Checkbox(l10n::Label(l10n::T("Show my nameplate to other players"), "show_my_nameplate_to_other_players"), &on)) coop::config::SetValue(::coop::config_registry::rows::nameplate, on ? "1" : "0"); - ImGui::TextDisabled("Off = your floating name/health bar disappears on every peer's"); - ImGui::TextDisabled("screen -- synced live and to late joiners; persists across sessions."); + ImGui::TextDisabled("%s", l10n::T("Off = your floating name/health bar disappears on every peer's\n" + "screen -- synced live and to late joiners; persists across sessions.")); // The local player's nick COLOUR preference. SYNCED (a live NickColorChange plus the Join // colour field for late joiners) and persisted (multivoid.ini nick_color=). Committed on a // debounce after the last edit rather than per drag frame, since each commit persists and // announces over the wire; the debounce and its reason are at the picker below. ImGui::Spacing(); - ImGui::SeparatorText("Nickname color"); + ImGui::SeparatorText(l10n::Label(l10n::T("Nickname color"), "nickname_color")); const uint32_t cur = coop::nick_color::LocalPacked(); bool custom = coop::nick_color::IsCustom(cur); // The picker's WORKING color: re-seeded only when the live pref changes @@ -107,7 +107,7 @@ void RenderNameplatePref() { static_cast(sCol[1] * 255.f + 0.5f), static_cast(sCol[2] * 255.f + 0.5f)); }; - if (ImGui::Checkbox("Custom nickname color", &custom)) { + if (ImGui::Checkbox(l10n::Label(l10n::T("Custom nickname color"), "custom_nickname_color"), &custom)) { sDirty = false; // the toggle IS the commit coop::config::SetValue(::coop::config_registry::rows::nick_color, coop::nick_color::IniTextFor(custom ? packWorking() : 0u).c_str()); @@ -134,8 +134,8 @@ void RenderNameplatePref() { coop::nick_color::IniTextFor(packed).c_str()); } } - ImGui::TextDisabled("Colors your nick everywhere it shows -- nameplate, chat, player list --"); - ImGui::TextDisabled("on every peer's screen. Synced live and to late joiners; persists."); + ImGui::TextDisabled("%s", l10n::T("Colors your nick everywhere it shows -- nameplate, chat, player list --\n" + "on every peer's screen. Synced live and to late joiners; persists.")); } // Overlay fonts -- GRANULAR per surface: chat, the net-stats widget, the nameplates @@ -146,7 +146,7 @@ void RenderFontPref() { const char* famItems[F::kFamilyCount]; for (int i = 0; i < F::kFamilyCount; ++i) famItems[i] = F::FamilyLabel(static_cast(i)); - ImGui::TextUnformatted("Overlay fonts (per surface):"); + ImGui::TextUnformatted(l10n::T("Overlay fonts (per surface):")); for (int r = 0; r < F::kRoleCount; ++r) { const auto role = static_cast(r); int cur = static_cast(F::RoleFamily(role)); @@ -157,16 +157,16 @@ void RenderFontPref() { F::FamilyToken(static_cast(cur))); ImGui::PopID(); } - ImGui::TextDisabled("Each surface picks its own family; applies on the next frame."); - ImGui::TextDisabled("Saved to multivoid.ini (ui.font.menu/chat/net/nameplate/toast,"); - ImGui::TextDisabled("each with its own default). Fixedsys (VOTV) = game terminal pixel"); - ImGui::TextDisabled("font; JetBrains/Cascadia monospace; Roboto proportional."); + ImGui::TextDisabled("%s", l10n::T("Each surface picks its own family; applies on the next frame.")); + ImGui::TextDisabled("%s", l10n::T("Saved to multivoid.ini (ui.font.menu/chat/net/nameplate/toast,\n" + "each with its own default). Fixedsys (VOTV) = game terminal pixel\n" + "font; JetBrains/Cascadia monospace; Roboto proportional.")); // UI size: a multiplier on top of the resolution factor. Applied on slider // RELEASE, not per drag frame -- each apply re-bakes the font atlas (a // one-frame ~100 ms hitch). ImGui::Spacing(); - ImGui::SeparatorText("UI size"); + ImGui::SeparatorText(l10n::Label(l10n::T("UI size"), "ui_size")); // sPending is the handle while it is dragged; sSeen is the applied value it last mirrored. The // render thread applies the row on its next frame, so the slider follows the applied value. static float sPending = ui::scale::UserScale(); @@ -187,10 +187,10 @@ void RenderFontPref() { ImGui::SameLine(); ImGui::TextDisabled("(?)"); if (ImGui::IsItemHovered()) - ImGui::SetTooltip("Scales the WHOLE overlay (menus, chat, nameplates) on top of the\n" - "automatic resolution scale. Applies when you release the slider\n" - "(the fonts re-bake). Saved to multivoid.ini (ui.scale)."); - ImGui::TextDisabled("Everything scales with the screen automatically; this is your extra zoom."); + ImGui::SetTooltip("%s", l10n::T("Scales the WHOLE overlay (menus, chat, nameplates) on top of the\n" + "automatic resolution scale. Applies when you release the slider\n" + "(the fonts re-bake). Saved to multivoid.ini (ui.scale).")); + ImGui::TextDisabled("%s", l10n::T("Everything scales with the screen automatically; this is your extra zoom.")); } // F1 > Administration > Players (host-role-gated; ui/admin_panel owns the pane). diff --git a/src/votv-coop/src/ui/net_stats_panel.cpp b/src/votv-coop/src/ui/net_stats_panel.cpp index 67dd54bc5..86d978990 100644 --- a/src/votv-coop/src/ui/net_stats_panel.cpp +++ b/src/votv-coop/src/ui/net_stats_panel.cpp @@ -2,6 +2,8 @@ #include "ui/net_stats_panel.h" +#include "l10n/l10n.h" + #include "coop/net/net_stats.h" #include "coop/config/config.h" #include "ui/fonts.h" @@ -193,15 +195,15 @@ void Render() { s.connected ? IM_COL32(120, 230, 130, 255) : IM_COL32(120, 125, 135, 255)); ImGui::SetCursorScreenPos(ImVec2(pos.x + S(16.f), pos.y)); - char hdr[64]; + char hdr[256]; + const auto peers = static_cast(s.peers); if (!s.connected) - std::snprintf(hdr, sizeof(hdr), "NET offline"); + std::snprintf(hdr, sizeof(hdr), "%s", l10n::T("NET offline")); else if (s.pingMaxMs >= 0) - std::snprintf(hdr, sizeof(hdr), "NET %d peer%s %d ms", - s.peers, s.peers == 1 ? "" : "s", s.pingMaxMs); + l10n::Fmt(hdr, sizeof(hdr), l10n::Tn("NET %1$llu peer %2$d ms", "NET %1$llu peers %2$d ms", peers), + peers, s.pingMaxMs); else - std::snprintf(hdr, sizeof(hdr), "NET %d peer%s", - s.peers, s.peers == 1 ? "" : "s"); + l10n::Fmt(hdr, sizeof(hdr), l10n::Tn("NET %llu peer", "NET %llu peers", peers), peers); ImGui::PushStyleColor(ImGuiCol_Text, kBright); ImGui::TextUnformatted(hdr); ImGui::PopStyleColor(); @@ -215,8 +217,8 @@ void Render() { ImGui::Spacing(); Sparkline(dl, rowW, S(30.f)); - char pk[64]; - std::snprintf(pk, sizeof(pk), "pkt/s %.0f in %.0f out", s.inPktps, s.outPktps); + char pk[256]; + l10n::Fmt(pk, sizeof(pk), l10n::T("pkt/s %1$.0f in %2$.0f out"), s.inPktps, s.outPktps); ImGui::PushStyleColor(ImGuiCol_Text, kDim); ImGui::TextUnformatted(pk); ImGui::PopStyleColor(); @@ -229,16 +231,16 @@ void Render() { void RenderMenuPref() { bool on = Enabled(); - if (ImGui::Checkbox("Show network stats overlay", &on)) + if (ImGui::Checkbox(l10n::Label(l10n::T("Show network stats overlay"), "show_network_stats_overlay"), &on)) coop::config::SetValue(::coop::config_registry::rows::ui_netstats, on ? "1" : "0"); ImGui::SameLine(); ImGui::TextDisabled("(?)"); if (ImGui::IsItemHovered()) - ImGui::SetTooltip("A compact live panel in the top-right corner: receive/send rate right\n" - "now (wire-level, includes protocol overhead), total downloaded/uploaded\n" - "this session, packets/s, peers + ping, and a 60 s rate graph.\n" - "Works for host and clients. Saved to multivoid.ini (ui.netstats)."); - ImGui::TextDisabled("Off by default; persists across sessions."); + ImGui::SetTooltip("%s", l10n::T("A compact live panel in the top-right corner: receive/send rate right\n" + "now (wire-level, includes protocol overhead), total downloaded/uploaded\n" + "this session, packets/s, peers + ping, and a 60 s rate graph.\n" + "Works for host and clients. Saved to multivoid.ini (ui.netstats).")); + ImGui::TextDisabled("%s", l10n::T("Off by default; persists across sessions.")); // Live readout -- doubles as a preview while the overlay itself is off. NS::Snapshot s; @@ -249,15 +251,21 @@ void RenderMenuPref() { FmtBytes(s.bytesRecv, totIn, sizeof(totIn)); FmtBytes(s.bytesSent, totOut, sizeof(totOut)); ImGui::Spacing(); - ImGui::SeparatorText("Session"); - if (!s.connected) ImGui::TextDisabled("No active session."); - ImGui::Text("Down: %s now, %s total, %llu packets", - rateIn, totIn, static_cast(s.packetsRecv)); - ImGui::Text("Up: %s now, %s total, %llu packets", - rateOut, totOut, static_cast(s.packetsSent)); + ImGui::SeparatorText(l10n::Label(l10n::T("Session"), "session")); + if (!s.connected) ImGui::TextDisabled("%s", l10n::T("No active session.")); + char line[256]; + l10n::Fmt(line, sizeof(line), l10n::T("Down: %1$s now, %2$s total, %3$llu packets"), + rateIn, totIn, static_cast(s.packetsRecv)); + ImGui::TextUnformatted(line); + l10n::Fmt(line, sizeof(line), l10n::T("Up: %1$s now, %2$s total, %3$llu packets"), + rateOut, totOut, static_cast(s.packetsSent)); + ImGui::TextUnformatted(line); if (s.connected) { - if (s.pingMaxMs >= 0) ImGui::Text("Peers: %d, worst ping %d ms", s.peers, s.pingMaxMs); - else ImGui::Text("Peers: %d", s.peers); + if (s.pingMaxMs >= 0) + l10n::Fmt(line, sizeof(line), l10n::T("Peers: %1$d, worst ping %2$d ms"), s.peers, s.pingMaxMs); + else + l10n::Fmt(line, sizeof(line), l10n::T("Peers: %d"), s.peers); + ImGui::TextUnformatted(line); } } diff --git a/src/votv-coop/src/ui/skins_panel.cpp b/src/votv-coop/src/ui/skins_panel.cpp index 2d403d44c..0feebfcac 100644 --- a/src/votv-coop/src/ui/skins_panel.cpp +++ b/src/votv-coop/src/ui/skins_panel.cpp @@ -2,6 +2,8 @@ #include "ui/skins_panel.h" +#include "l10n/l10n.h" + #include "coop/player/local_body.h" #include "coop/player/skin_registry.h" #include "coop/player/skin_preview.h" // the live in-world mannequin preview on hover @@ -57,16 +59,16 @@ void Render() { const auto& entries = coop::skins::Entries(); const std::string current = coop::local_body::LocalSkinNameCopy(); - ImGui::TextWrapped("Your body skin -- what YOU see looking down and what OTHERS see. " - "Saved to multivoid.ini; restored on rejoin."); - ImGui::TextDisabled("Skins come from converter .pak files under Content/Paks/LogicMods " - "(any subfolder). One pak may carry several skins -- the four " - "scientists ship together in scientists.pak."); - ImGui::TextDisabled("Preview tile = .png/.bmp beside the pak."); - ImGui::TextDisabled("Peers WITHOUT that pak see the default kel body instead."); - ImGui::TextDisabled("A pak that carries no skin (another mod's) is dropped from this " - "list once the game has been asked -- it is not a skin."); - if (ImGui::Button("Refresh list")) { + ImGui::TextWrapped("%s", l10n::T("Your body skin -- what YOU see looking down and what OTHERS see. " + "Saved to multivoid.ini; restored on rejoin.")); + ImGui::TextDisabled("%s", l10n::T("Skins come from converter .pak files under Content/Paks/LogicMods " + "(any subfolder). One pak may carry several skins -- the four " + "scientists ship together in scientists.pak.")); + ImGui::TextDisabled("%s", l10n::T("Preview tile = .png/.bmp beside the pak.")); + ImGui::TextDisabled("%s", l10n::T("Peers WITHOUT that pak see the default kel body instead.")); + ImGui::TextDisabled("%s", l10n::T("A pak that carries no skin (another mod's) is dropped from this " + "list once the game has been asked -- it is not a skin.")); + if (ImGui::Button(l10n::Label(l10n::T("Refresh list"), "refresh_list"))) { PublishNamesForResolve(coop::skins::Entries(true)); // Release the cached preview textures before dropping the map -- clearing // alone leaks one texture+SRV per preview per refresh. @@ -75,7 +77,9 @@ void Render() { g_previews.clear(); } ImGui::SameLine(); - ImGui::TextDisabled("current: %s", current.c_str()); + char currentLine[256]; + l10n::Fmt(currentLine, sizeof(currentLine), l10n::T("current: %s"), current.c_str()); + ImGui::TextDisabled("%s", currentLine); ImGui::Separator(); // gmod-style tile grid: as many tiles per row as the pane fits. @@ -122,8 +126,9 @@ void Render() { static_cast(reinterpret_cast(pv.srv)), ImVec2(iw, ih)); } else { - clicked = ImGui::Button(e.name == coop::skins::kNativeSkinName ? "Dr. Kel\n(default)" - : "(no preview)", + clicked = ImGui::Button(e.name == coop::skins::kNativeSkinName + ? l10n::Label(l10n::T("Dr. Kel\n(default)"), "dr_kel_default_") + : l10n::Label(l10n::T("(no preview)"), "_no_preview_"), ImVec2(tileW, tileImgH)); } // Hover -> live mannequin preview of this skin. diff --git a/src/votv-coop/src/ui/world_rules_panel.cpp b/src/votv-coop/src/ui/world_rules_panel.cpp index 2fa322006..e9e8b0cda 100644 --- a/src/votv-coop/src/ui/world_rules_panel.cpp +++ b/src/votv-coop/src/ui/world_rules_panel.cpp @@ -2,6 +2,8 @@ #include "ui/world_rules_panel.h" +#include "l10n/l10n.h" + #include "ue_wrap/world/game_rules.h" #include "ue_wrap/world/game_rules_pane.h" #include "ue_wrap/core/game_thread.h" @@ -76,7 +78,7 @@ ViewRow MakeRow(const GR::RuleField& f, const GP::Row* row, const GP::LockInputs case GR::Kind::Bool: out.isCheck = true; out.on = f.bval; - out.value = f.bval ? "On" : "Off"; + out.value = f.bval ? l10n::Tc("f1", "On") : l10n::Tc("f1", "Off"); break; case GR::Kind::Float: std::snprintf(buf, sizeof(buf), "%.*f", row ? row->sliderDecimals : 2, f.fval); @@ -97,10 +99,13 @@ ViewRow MakeRow(const GR::RuleField& f, const GP::Row* row, const GP::LockInputs out.locked = true; out.greyed = true; if (row->unlockAchievements.empty()) { - std::snprintf(buf, sizeof(buf), "%d", row->unlockDay); - out.description = std::string("This rule is day-locked!\nUnlock this rule by playing past day ") + buf; + char line[256]; + l10n::Fmt(line, sizeof(line), + l10n::T("This rule is day-locked!\nUnlock this rule by playing past day %d"), row->unlockDay); + out.description = line; } else { - out.description = "This rule is achievement-locked!\nUnlock this rule by getting following achievements:"; + out.description = l10n::T("This rule is achievement-locked!\n" + "Unlock this rule by getting following achievements:"); for (const std::string& a : row->unlockAchievements) { const bool held = std::find(lock.achievements.begin(), lock.achievements.end(), a) != lock.achievements.end(); @@ -194,9 +199,9 @@ void Render() { g_lastFrame = frame; s_worldGen = worldGen; - if (ImGui::SmallButton("Refresh")) RequestSnapshot(); + if (ImGui::SmallButton(l10n::Label(l10n::Tc("f1", "Refresh"), "refresh"))) RequestSnapshot(); ImGui::SameLine(); - ImGui::TextDisabled("Read-only. The host's save sets the rules for everyone."); + ImGui::TextDisabled("%s", l10n::T("Read-only. The host's save sets the rules for everyone.")); static View s_view; // render thread only static int s_viewGen = 0; @@ -222,19 +227,22 @@ void Render() { } if (!s_view.valid) { ImGui::Spacing(); - ImGui::TextDisabled(gen == 0 ? "Reading rules..." : "World rules unavailable (still loading?)."); + ImGui::TextDisabled("%s", gen == 0 ? l10n::T("Reading rules...") + : l10n::T("World rules unavailable (still loading?).")); return; } ImGui::Spacing(); - ImGui::Text("Gamemode:"); + ImGui::TextUnformatted(l10n::T("Gamemode:")); ImGui::SameLine(); ImGui::TextColored(ImVec4(0.55f, 0.82f, 1.00f, 1.00f), "%s", s_view.gamemode.empty() ? "?" : s_view.gamemode.c_str()); // The two copies the game keeps must agree; when they do not, this peer is not under the rules // its world was saved with, and saying so beats showing either copy as the truth. if (s_view.differFromSaved > 0) { - ImGui::TextColored(ImVec4(1.f, 0.6f, 0.2f, 1.f), "%d rule(s) in force differ from the rules saved with this world.", - s_view.differFromSaved); + char differ[256]; + l10n::Fmt(differ, sizeof(differ), l10n::T("%d rule(s) in force differ from the rules saved with this world."), + s_view.differFromSaved); + ImGui::TextColored(ImVec4(1.f, 0.6f, 0.2f, 1.f), "%s", differ); } ImGui::Separator(); @@ -258,8 +266,8 @@ void Render() { if (!ImGui::CollapsingHeader(cat.name.c_str(), ImGuiTreeNodeFlags_DefaultOpen)) continue; if (cat.rows.empty()) continue; // a category the game shows with nothing in it: header only if (!ImGui::BeginTable(cat.name.c_str(), 2, ImGuiTableFlags_RowBg | ImGuiTableFlags_PadOuterX)) continue; - ImGui::TableSetupColumn("Rule", ImGuiTableColumnFlags_WidthStretch); - ImGui::TableSetupColumn("Value", ImGuiTableColumnFlags_WidthFixed, S(96.f)); + ImGui::TableSetupColumn(l10n::Label(l10n::T("Rule"), "rule"), ImGuiTableColumnFlags_WidthStretch); + ImGui::TableSetupColumn(l10n::Label(l10n::T("Value"), "value"), ImGuiTableColumnFlags_WidthFixed, S(96.f)); for (const ViewRow& row : cat.rows) { ImGui::TableNextRow(); // The game lays one overlay image over the row, and only one: img_locked's red when the @@ -288,11 +296,11 @@ void Render() { ImGui::Separator(); ImGui::BeginChild("##world_rules_desc", ImVec2(0, 0)); if (!selected) { - ImGui::TextDisabled("Click on the name of the rule to see its description."); + ImGui::TextDisabled("%s", l10n::T("Click on the name of the rule to see its description.")); } else { ImGui::TextUnformatted(selected->label.c_str()); ImGui::PushTextWrapPos(0.f); - if (selected->description.empty()) ImGui::TextDisabled("(no description)"); + if (selected->description.empty()) ImGui::TextDisabled("%s", l10n::T("(no description)")); else ImGui::TextUnformatted(selected->description.c_str()); ImGui::PopTextWrapPos(); } From 1d3b18978c6ca95d0c0e0afa684b0aceeda3ff1a Mon Sep 17 00:00:00 2001 From: pelmentor Date: Sat, 10 Oct 2026 06:21:54 +0600 Subject: [PATCH 083/306] [dev] the l10n drill's header names its lines instead of quoting them The header quoted the DONE line across two comment lines, a shape no format in the tree sends, which the public prose gate counts as a dead log marker; it now says what the lines carry. Co-Authored-By: Claude Opus 5.5 --- src/votv-coop/include/coop/dev/l10n_drill.h | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/votv-coop/include/coop/dev/l10n_drill.h b/src/votv-coop/include/coop/dev/l10n_drill.h index cb68e82d4..cb2d8fabf 100644 --- a/src/votv-coop/include/coop/dev/l10n_drill.h +++ b/src/votv-coop/include/coop/dev/l10n_drill.h @@ -8,9 +8,9 @@ // translation (l10n::WasFound), the join line by which of its two forms the feed holds. `translated` // passes when all three are translated; `english` when the interface is English and none is -- so the // per-peer arm (an English host, a translated client) has a pass on both peers, and `translated` under -// an English language is the red. Lines: "[L10N-DRILL] DONE expect= -// locale= found=/ distinct=" or "[L10N-DRILL] FAIL expect= ...". Readiness throughout, -// never a clock: a run that never gets there ends on the rig's budget with no line. +// an English language is the red. It prints a DONE or a FAIL line tagged [L10N-DRILL] naming the +// expectation, the locale and what was found or missing. Readiness throughout, never a clock: a run +// that never gets there ends on the rig's budget with no line. #pragma once From 444b7437aaf91bc980a97bfef78b8da1b2bf642a Mon Sep 17 00:00:00 2001 From: pelmentor Date: Sat, 10 Oct 2026 06:22:49 +0600 Subject: [PATCH 084/306] [ui] two counts take their plural, two fallback names their own id The rules that differ from the saved copy and the F1 Stats pane's packet counts are plural sentences now, so English reads "1 rule ... differs" and "1 packet". The world rules pane's own fallback categories, Rules and Other, are translated in the header and keep their English name as the header's and the table's id, the game's category names being both. Co-Authored-By: Claude Opus 5.5 --- src/votv-coop/src/ui/net_stats_panel.cpp | 8 ++++++-- src/votv-coop/src/ui/world_rules_panel.cpp | 22 +++++++++++++++------- 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/src/votv-coop/src/ui/net_stats_panel.cpp b/src/votv-coop/src/ui/net_stats_panel.cpp index 86d978990..bb055b39d 100644 --- a/src/votv-coop/src/ui/net_stats_panel.cpp +++ b/src/votv-coop/src/ui/net_stats_panel.cpp @@ -254,10 +254,14 @@ void RenderMenuPref() { ImGui::SeparatorText(l10n::Label(l10n::T("Session"), "session")); if (!s.connected) ImGui::TextDisabled("%s", l10n::T("No active session.")); char line[256]; - l10n::Fmt(line, sizeof(line), l10n::T("Down: %1$s now, %2$s total, %3$llu packets"), + l10n::Fmt(line, sizeof(line), + l10n::Tn("Down: %1$s now, %2$s total, %3$llu packet", "Down: %1$s now, %2$s total, %3$llu packets", + s.packetsRecv), rateIn, totIn, static_cast(s.packetsRecv)); ImGui::TextUnformatted(line); - l10n::Fmt(line, sizeof(line), l10n::T("Up: %1$s now, %2$s total, %3$llu packets"), + l10n::Fmt(line, sizeof(line), + l10n::Tn("Up: %1$s now, %2$s total, %3$llu packet", "Up: %1$s now, %2$s total, %3$llu packets", + s.packetsSent), rateOut, totOut, static_cast(s.packetsSent)); ImGui::TextUnformatted(line); if (s.connected) { diff --git a/src/votv-coop/src/ui/world_rules_panel.cpp b/src/votv-coop/src/ui/world_rules_panel.cpp index e9e8b0cda..d0f4cc6b8 100644 --- a/src/votv-coop/src/ui/world_rules_panel.cpp +++ b/src/votv-coop/src/ui/world_rules_panel.cpp @@ -43,7 +43,9 @@ struct ViewRow { // img_disabled (white) when it is merely switched off. }; struct ViewCategory { - std::string name; + std::string name; // what the header shows: the game's category name, or ours translated + std::string id; // the header's and the table's ImGui id: the name in English, so a + // translation never moves the header's open state or the table's columns std::vector rows; }; struct View { @@ -147,7 +149,8 @@ void BuildAndPublish() { std::vector placed(snap.fields.size(), false); for (const GP::Category& cat : g_pane.categories) { ViewCategory vc; - vc.name = cat.name.empty() ? "Rules" : cat.name; + vc.id = cat.name.empty() ? std::string("Rules") : cat.name; + vc.name = cat.name.empty() ? std::string(l10n::T("Rules")) : cat.name; for (const GP::Row& row : cat.rows) { const GR::RuleField* f = GR::NthOfKind(snap.fields, KindOf(row.control), row.index); if (!f) continue; @@ -164,7 +167,8 @@ void BuildAndPublish() { // Rules the game's pane does not place: all of them when its layout could not be read, and // any rule a game update adds before its pane does. ViewCategory other; - other.name = g_pane.valid ? "Other" : "Rules"; + other.id = g_pane.valid ? "Other" : "Rules"; + other.name = g_pane.valid ? l10n::T("Other") : l10n::T("Rules"); for (size_t i = 0; i < snap.fields.size(); ++i) { if (!placed[i]) other.rows.push_back(MakeRow(snap.fields[i], nullptr, GP::LockInputs{})); } @@ -240,8 +244,11 @@ void Render() { // its world was saved with, and saying so beats showing either copy as the truth. if (s_view.differFromSaved > 0) { char differ[256]; - l10n::Fmt(differ, sizeof(differ), l10n::T("%d rule(s) in force differ from the rules saved with this world."), - s_view.differFromSaved); + const auto n = static_cast(s_view.differFromSaved); + l10n::Fmt(differ, sizeof(differ), + l10n::Tn("%llu rule in force differs from the rules saved with this world.", + "%llu rules in force differ from the rules saved with this world.", n), + n); ImGui::TextColored(ImVec4(1.f, 0.6f, 0.2f, 1.f), "%s", differ); } ImGui::Separator(); @@ -263,9 +270,10 @@ void Render() { const float paneHeight = S(96.f); ImGui::BeginChild("##world_rules_scroll", ImVec2(0, -paneHeight)); for (const ViewCategory& cat : s_view.categories) { - if (!ImGui::CollapsingHeader(cat.name.c_str(), ImGuiTreeNodeFlags_DefaultOpen)) continue; + if (!ImGui::CollapsingHeader(l10n::Label(cat.name.c_str(), cat.id.c_str()), ImGuiTreeNodeFlags_DefaultOpen)) + continue; if (cat.rows.empty()) continue; // a category the game shows with nothing in it: header only - if (!ImGui::BeginTable(cat.name.c_str(), 2, ImGuiTableFlags_RowBg | ImGuiTableFlags_PadOuterX)) continue; + if (!ImGui::BeginTable(cat.id.c_str(), 2, ImGuiTableFlags_RowBg | ImGuiTableFlags_PadOuterX)) continue; ImGui::TableSetupColumn(l10n::Label(l10n::T("Rule"), "rule"), ImGuiTableColumnFlags_WidthStretch); ImGui::TableSetupColumn(l10n::Label(l10n::T("Value"), "value"), ImGuiTableColumnFlags_WidthFixed, S(96.f)); for (const ViewRow& row : cat.rows) { From 4438f1709dc5a105329411d02b210fbdfaf126df Mon Sep 17 00:00:00 2001 From: pelmentor Date: Sat, 10 Oct 2026 06:30:18 +0600 Subject: [PATCH 085/306] [ui] the server browser in the player's language The native browser (window title, Back and the action buttons, the details and status panes, the notices) and its three input windows look every drawn string up through l10n. A sentence with values is one msgid through Fmt, the server count is a plural pair, the button tables are marked where defined and translated where drawn, and a lone button word takes the server_browser context. Left as they are: the boot-failure dialog's text, master labels, version and build identifiers, the "!b146" mismatch mark, outcome tokens, list separators, log lines and every reflected name. The ImGui browser is untouched. Co-Authored-By: Claude Opus 5.5 --- .../src/ui/browser_input_screens.cpp | 75 +++++++---- .../src/ui/server_browser_actions.cpp | 37 +++-- .../src/ui/server_browser_native.cpp | 24 +++- .../src/ui/server_browser_panels.cpp | 127 +++++++++++++----- src/votv-coop/src/ui/server_browser_rows.cpp | 14 +- 5 files changed, 192 insertions(+), 85 deletions(-) diff --git a/src/votv-coop/src/ui/browser_input_screens.cpp b/src/votv-coop/src/ui/browser_input_screens.cpp index d869daddd..455e7745a 100644 --- a/src/votv-coop/src/ui/browser_input_screens.cpp +++ b/src/votv-coop/src/ui/browser_input_screens.cpp @@ -4,6 +4,8 @@ #include "ui/browser_input_screens.h" +#include "l10n/l10n.h" + #include "coop/config/config.h" #include "coop/config/config_registry.h" #include "coop/session/session_manager.h" @@ -22,11 +24,17 @@ #include #include +#include #include namespace ui::browser_input_screens { namespace { +// A looked-up label is UTF-8; the native widgets take wide text. +std::wstring Wide(const char* utf8) { + return coop::text::FromUtf8Lossy(utf8, std::strlen(utf8)); +} + namespace E = ue_wrap::engine; namespace U = ue_wrap::umg; namespace P = ue_wrap::profile; @@ -105,28 +113,33 @@ int Idx(Kind k) { } // What each screen says, in one table so the screens cannot drift into different idioms. +// The texts are marked English (msgids), looked up where they are drawn; the confirm word is a +// button's lone word and takes the surface's context. struct Spec { - const wchar_t* title; - const wchar_t* label; - const wchar_t* hint; - const wchar_t* confirm; - int32_t maxLen; + const char* title; + const char* label; + const char* hint; + const char* confirm; + int32_t maxLen; // An optional second field; a null label means one box, which is every window but Direct // connect. A Spec row rather than a Kind branch in the builder, so the two shapes differ in // data, not in construction. - const wchar_t* label2 = nullptr; - const wchar_t* hint2 = nullptr; - int32_t maxLen2 = 0; + const char* label2 = nullptr; + const char* hint2 = nullptr; + int32_t maxLen2 = 0; }; const Spec kSpec[3] = { // Two boxes: the address, and a password left empty when the server has none. The password cap // matches the LobbyPassword window's. - {L"Multivoid - Direct connect", L"Server address", L"host or host:port", L"Connect", 64, - L"Password (leave empty if the server has none)", L"password", 64}, - {L"Multivoid - Change name", L"Your name", L"your name", L"OK", 24}, + {L10N_MARK("Multivoid - Direct connect"), L10N_MARK("Server address"), + L10N_MARK("host or host:port"), L10N_MARK_C("server_browser", "Connect"), 64, + L10N_MARK("Password (leave empty if the server has none)"), L10N_MARK("password"), 64}, + {L10N_MARK("Multivoid - Change name"), L10N_MARK("Your name"), L10N_MARK("your name"), + L10N_MARK_C("server_browser", "OK"), 24}, // The cap is 64 codepoints, not the generated length: a host may replace the generated password // with anything they can say out loud. - {L"Multivoid - Password", L"Server password", L"password", L"Join", 64}, + {L10N_MARK("Multivoid - Password"), L10N_MARK("Server password"), L10N_MARK("password"), + L10N_MARK_C("server_browser", "Join"), 64}, }; // Every field this screen owns, released together. Release is the only thing that clears the @@ -153,15 +166,17 @@ bool BuildOne(void* switcher, Kind kind, void* backDonor) { NS::WindowShell shell; const float windowH = kWindowH + (spec.label2 ? kSecondFieldH : 0.f); - if (!NS::BuildWindowShell(switcher, kWindowW, windowH, spec.title, shell)) return false; + if (!NS::BuildWindowShell(switcher, kWindowW, windowH, Wide(l10n::T(spec.title)).c_str(), + shell)) + return false; void* col = shell.column; - NS::AddText(col, spec.label, 16, kAccent, NS::kJustLeft, 0.f); - s.field = TF::Create(col, spec.hint, spec.maxLen, kFieldW); + NS::AddText(col, Wide(l10n::T(spec.label)).c_str(), 16, kAccent, NS::kJustLeft, 0.f); + s.field = TF::Create(col, Wide(l10n::T(spec.hint)).c_str(), spec.maxLen, kFieldW); if (!s.field) return false; if (spec.label2) { - NS::AddText(col, spec.label2, 16, kAccent, NS::kJustLeft, 0.f); - s.field2 = TF::Create(col, spec.hint2, spec.maxLen2, kFieldW); + NS::AddText(col, Wide(l10n::T(spec.label2)).c_str(), 16, kAccent, NS::kJustLeft, 0.f); + s.field2 = TF::Create(col, Wide(l10n::T(spec.hint2)).c_str(), spec.maxLen2, kFieldW); // The first field is released on the way out: this builder is retried from the menu tick, // and a bare return past a successful Create would leak a Field per retry. if (!s.field2) { ReleaseFields(s); return false; } @@ -178,10 +193,14 @@ bool BuildOne(void* switcher, Kind kind, void* backDonor) { // Footer: Back at the left, the confirm at the right, where every native VOTV window puts its // confirm. if (void* footRow = NS::Spawn(L"HorizontalBox", col)) { - s.backBtn = NS::BuildButton(footRow, backDonor, L"Back", NS::kBtnFontPx); + s.backBtn = NS::BuildButton(footRow, backDonor, + Wide(l10n::Tc("server_browser", "Back")).c_str(), + NS::kBtnFontPx); void* gap = NS::Spawn(L"Spacer", footRow); if (gap) NS::AddHFill(footRow, gap, 1.f, NS::kFill, NS::kFill); - s.okBtn = NS::BuildButton(footRow, backDonor, spec.confirm, NS::kBtnFontPx); + s.okBtn = NS::BuildButton(footRow, backDonor, + Wide(l10n::Tc("server_browser", spec.confirm)).c_str(), + NS::kBtnFontPx); // Released here too: the next tick rebuilds and mints a second Field, stranding this one in // the module's live list. if (!s.backBtn || !s.okBtn) { ReleaseFields(s); return false; } @@ -196,7 +215,7 @@ bool BuildOne(void* switcher, Kind kind, void* backDonor) { void* slot = U::AddChild(switcher, s.root); s.index = U::IndexOfChild(switcher, s.root); if (s.index < 0) { - UE_LOGE("browser_input_screens: built '%ls' but could NOT place it in the menu " + UE_LOGE("browser_input_screens: built '%s' but could NOT place it in the menu " "switcher (AddChild slot=%p, GetChildIndex=-1) -- it cannot be shown this " "menu", spec.title, slot); // The field goes with it: clearing the root alone leaves the heap Field alive and in the @@ -218,7 +237,7 @@ void Confirm(Kind kind) { const std::string value = TF::Text(s.field); if (kind == Kind::LobbyPassword) { if (value.empty()) { - SetStatus(s, "Type the password this server was locked with.", kBad); + SetStatus(s, l10n::T("Type the password this server was locked with."), kBad); return; } // Handed over, never stored: SetJoinPassword holds it for one join attempt and nothing @@ -229,8 +248,8 @@ void Confirm(Kind kind) { g_pendingJoin.hostGame)) { // Dropped on a refusal, so it cannot ride into the next connection the player makes. sm::SetJoinPassword(""); - SetStatus(s, "Could not start that connection -- another action is already " - "in flight.", kBad); + SetStatus(s, l10n::T("Could not start that connection -- another action is already " + "in flight."), kBad); return; } UE_LOGI("browser_input_screens: join with a password accepted -- join_progress " @@ -240,7 +259,7 @@ void Confirm(Kind kind) { } if (kind == Kind::ChangeName) { if (!sm::SetNickname(value)) { - SetStatus(s, "Type a name first.", kBad); + SetStatus(s, l10n::T("Type a name first."), kBad); return; } coop::config::SetValue(::coop::config_registry::rows::net_nick, sm::Nickname().c_str()); @@ -251,7 +270,7 @@ void Confirm(Kind kind) { } if (value.empty()) { - SetStatus(s, "Type an address first -- host or host:port.", kBad); + SetStatus(s, l10n::T("Type an address first -- host or host:port."), kBad); return; } // ConnectDirect owns the refusal: it parses the address and answers false for a bad one, so @@ -262,8 +281,8 @@ void Confirm(Kind kind) { // password is non-empty, and its check sits inside that branch. sm::SetJoinPassword(s.field2 ? TF::Text(s.field2) : std::string()); if (!sm::ConnectDirect(value)) { - SetStatus(s, "Could not connect to that address -- check it, or another action is " - "already in flight.", kBad); + SetStatus(s, l10n::T("Could not connect to that address -- check it, or another action is " + "already in flight."), kBad); return; } // Written only after the accept gate passed: the row means the last address actually tried and @@ -305,7 +324,7 @@ void Show(Kind kind) { // Focused on open, on the box the player always fills in. Tab moves to the other; so does a // click, since the field hit-tests its own box by geometry. TF::Focus(s.field); - UE_LOGI("browser_input_screens: shown '%ls' (index %d -> %d)", + UE_LOGI("browser_input_screens: shown '%s' (index %d -> %d)", kSpec[Idx(kind)].title, g_priorIndex, s.index); } diff --git a/src/votv-coop/src/ui/server_browser_actions.cpp b/src/votv-coop/src/ui/server_browser_actions.cpp index 1c1297242..3f5172d26 100644 --- a/src/votv-coop/src/ui/server_browser_actions.cpp +++ b/src/votv-coop/src/ui/server_browser_actions.cpp @@ -2,6 +2,8 @@ #include "ui/server_browser_actions.h" +#include "l10n/l10n.h" + #include "ui/server_browser_native.h" // the selection these act on, and the notice line #include "ui/browser_input_screens.h" // where the two input doors lead #include "ui/host_window_native.h" // what HOST opens @@ -12,12 +14,14 @@ #include "coop/net/lobby_client.h" #include "coop/session/session_manager.h" +#include "coop/text/utf8_codec.h" // a looked-up (UTF-8) label, widened for the native widgets #include "ue_wrap/core/log.h" #include "ue_wrap/core/sdk_profile.h" #include "ue_wrap/engine/engine.h" #include "ue_wrap/engine/umg_build.h" +#include #include namespace ui::server_browser_actions { @@ -30,6 +34,11 @@ namespace NS = ui::native_screen; namespace sm = coop::session_manager; namespace SB = ui::server_browser_native; +// A looked-up label is UTF-8; the native widgets take wide text. +std::wstring Wide(const char* utf8) { + return coop::text::FromUtf8Lossy(utf8, std::strlen(utf8)); +} + void* g_connect = nullptr; void* g_host = nullptr; void* g_refresh = nullptr; @@ -48,14 +57,14 @@ void DoConnect() { std::string master; // the master the row was listed on: the lobby id is its if (!SB::SelectedRow(row, &master)) { g_lastOutcome = "connect:none"; - SB::SetNotice("Pick a server from the list first."); + SB::SetNotice(l10n::T("Pick a server from the list first.")); return; } // A host cannot join itself. The join call refuses this too, so this is the message rather // than the guard; the guard lives at the one place that can enforce it. if (!row.lobbyId.empty() && row.lobbyId == sm::OwnLobbyId()) { g_lastOutcome = "connect:self"; - SB::SetNotice("That's your own server -- you're already hosting it."); + SB::SetNotice(l10n::T("That's your own server -- you're already hosting it.")); return; } // A locked row goes to the password prompt instead of straight at the host. The version is @@ -82,8 +91,8 @@ void DoConnect() { SB::Close(); // accepted: the loading screen owns the player from here } else { g_lastOutcome = "connect:busy"; - SB::SetNotice("Could not start that connection -- another action is already " - "in flight."); + SB::SetNotice(l10n::T("Could not start that connection -- another action is already " + "in flight.")); } } @@ -105,7 +114,7 @@ void DoHost() { void DoRefresh() { g_lastOutcome = "refresh"; sm::Refresh(); - SB::SetNotice("Refreshing the server list..."); + SB::SetNotice(l10n::T("Refreshing the server list...")); } // The two input doors. They author nothing themselves: the sibling window owns the value, @@ -134,7 +143,8 @@ bool BuildConnect(void* parent, void* donorBtn) { U::SetContent(rowBox, row); if (void* s = NS::AddVFill(parent, rowBox, 0.f, NS::kFill, NS::kTop)) NS::SetSlotPadding(s, P::off::UVerticalBoxSlot_Padding, 0.f, 0.f, 0.f, 6.f); - g_connect = NS::BuildButton(row, donorBtn, L"Connect", NS::kBtnFontPx); + g_connect = NS::BuildButton(row, donorBtn, Wide(l10n::Tc("server_browser", "Connect")).c_str(), + NS::kBtnFontPx); if (!g_connect) { UE_LOGE("server_browser_actions: could not build CONNECT -- the screen would have " "no way to join the server it is describing"); @@ -152,12 +162,12 @@ bool Build(void* parent, void* donorBtn) { // address, host your own, rename yourself, refetch the list. Sentence case, never caps: the // game uppercases no button label anywhere, and shouting was the single loudest way our // chrome read as foreign. - struct Cell { const wchar_t* label; void** out; }; + struct Cell { const char* label; void** out; }; const Cell cells[] = { - {L"Direct connect", &g_direct}, - {L"Host game", &g_host}, - {L"Change name", &g_rename}, - {L"Update list", &g_refresh}, + {L10N_MARK("Direct connect"), &g_direct}, + {L10N_MARK("Host game"), &g_host}, + {L10N_MARK("Change name"), &g_rename}, + {L10N_MARK("Update list"), &g_refresh}, }; const int cellCount = static_cast(sizeof(cells) / sizeof(cells[0])); @@ -183,9 +193,10 @@ bool Build(void* parent, void* donorBtn) { if (void* s = NS::AddVFill(parent, rowBox, 0.f, NS::kFill, NS::kTop)) NS::SetSlotPadding(s, P::off::UVerticalBoxSlot_Padding, 0.f, 0.f, 0.f, 4.f); } - void* btn = NS::BuildButton(row, donorBtn, cells[i].label, NS::kBtnFontPx); + void* btn = NS::BuildButton(row, donorBtn, Wide(l10n::T(cells[i].label)).c_str(), + NS::kBtnFontPx); if (!btn) { - UE_LOGE("server_browser_actions: could not build the '%ls' action -- the grid " + UE_LOGE("server_browser_actions: could not build the '%s' action -- the grid " "would ship with a hole", cells[i].label); return false; } diff --git a/src/votv-coop/src/ui/server_browser_native.cpp b/src/votv-coop/src/ui/server_browser_native.cpp index dc03ecfe8..e1b1f4fd8 100644 --- a/src/votv-coop/src/ui/server_browser_native.cpp +++ b/src/votv-coop/src/ui/server_browser_native.cpp @@ -4,10 +4,13 @@ #include "ui/server_browser_native.h" +#include "l10n/l10n.h" + #include "coop/config/config.h" #include "coop/config/config_registry.h" #include "coop/session/session_manager.h" -#include "ui/boot_warning_dialog.h" // the loud failure surface for a donor that never appears +#include "coop/text/utf8_codec.h" // a looked-up (UTF-8) label, widened for the native widgets +#include "ui/boot_warning_dialog.h" // the loud failure surface for a donor that never appears #include "ui/input_focus.h" // a click only counts while OUR window is foreground #include "ui/native_screen.h" // palette + widget primitives, shared with the host window #include "ui/server_browser_actions.h" // CONNECT / HOST / REFRESH, its own TU @@ -28,6 +31,7 @@ #include #include +#include #include namespace ui::server_browser_native { @@ -75,6 +79,11 @@ using NS::AddText; using NS::AddFramedBox; using NS::BuildButton; +// A looked-up label is UTF-8; the native widgets take wide text. +std::wstring Wide(const char* utf8) { + return coop::text::FromUtf8Lossy(utf8, std::strlen(utf8)); +} + // Only the window's colours; the row palette lives with the rows. const FLinearColor kPanel = NS::Panel(); // window fill const FLinearColor kText = NS::Text(); // the default: most text is white @@ -196,7 +205,7 @@ bool BuildScreen(void* switcher) { // authors its own frame: it is the canary for a moved menu layout. NS::WindowShell shell; if (!NS::BuildWindowShell(switcher, kWindowW, kWindowH, - L"Multivoid - Server Browser", shell)) + Wide(l10n::T("Multivoid - Server Browser")).c_str(), shell)) return false; void* root = shell.root; void* col = shell.column; @@ -257,7 +266,8 @@ bool BuildScreen(void* switcher) { // runs all the way down with no dead band beside Back. if (void* footRow = Spawn(L"HorizontalBox", leftCol)) { // Sentence case: VOTV uppercases no button label anywhere. - g_backBtn = BuildButton(footRow, backDonor, L"Back", ui::native_screen::kBtnFontPx); + g_backBtn = BuildButton(footRow, backDonor, Wide(l10n::Tc("server_browser", "Back")).c_str(), + ui::native_screen::kBtnFontPx); if (!g_backBtn) return false; NS::SetHSlot(NS::SlotOf(g_backBtn), 0.f, NS::kLeft, kCenter); if (void* s = NS::AddVFill(leftCol, footRow, 0.f, NS::kLeft, kBottom)) @@ -525,10 +535,10 @@ void OnMenuTick(void* menu, void* switcher) { g_lastRefreshMs = ::GetTickCount64(); sm::Refresh(); SyncRows(); - const std::string notice = - "Showing the " + coop::net::master_slots::Selected().label + - " server list."; - panels::SetNotice(notice.c_str()); + char notice[256]; + l10n::Fmt(notice, sizeof(notice), l10n::T("Showing the %s server list."), + coop::net::master_slots::Selected().label.c_str()); + panels::SetNotice(notice); } return; } diff --git a/src/votv-coop/src/ui/server_browser_panels.cpp b/src/votv-coop/src/ui/server_browser_panels.cpp index 27016ba10..f69b4f946 100644 --- a/src/votv-coop/src/ui/server_browser_panels.cpp +++ b/src/votv-coop/src/ui/server_browser_panels.cpp @@ -2,6 +2,8 @@ #include "ui/server_browser_panels.h" +#include "l10n/l10n.h" + #include "coop/net/lobby_client.h" #include "coop/net/master_slots.h" // the list the alarm names, and whether another exists #include "coop/net/protocol.h" // kProtocolVersion -- which side must update @@ -17,11 +19,17 @@ #include +#include #include namespace ui::server_browser_panels { namespace { +// A looked-up label is UTF-8; the native widgets take wide text. +std::wstring Wide(const char* utf8) { + return coop::text::FromUtf8Lossy(utf8, std::strlen(utf8)); +} + namespace E = ue_wrap::engine; namespace U = ue_wrap::umg; namespace P = ue_wrap::profile; @@ -142,21 +150,29 @@ void* DetailLine(void* col, int32_t size, const FLinearColor& c, bool wrap = fal return t; } -std::string Sec(int s) { return std::to_string(s) + "s ago"; } - // How the host's players reach it, counted by the link the host measures on each: the counts behind the // row's word, as a tail on the connection line. None when the host reports no one, which says nothing about -// who is in: a host or master older than the counts reports no one either. +// who is in: a host or master older than the counts reports no one either. Each count is a phrase of its +// own; the ", " between them and the " -- " before them are punctuation. std::string LinksTail(const coop::net::lobby::LobbyRow& r) { std::string parts; - const auto add = [&parts](int n, const char* what) { - if (n <= 0) return; + char one[256]; + const auto join = [&parts](const char* piece) { if (!parts.empty()) parts += ", "; - parts += std::to_string(n) + " " + what; + parts += piece; }; - add(r.links.relayed, "relayed"); - add(r.links.direct, "direct"); - add(r.links.lan, "LAN"); + if (r.links.relayed > 0) { + l10n::Fmt(one, sizeof(one), l10n::T("%d relayed"), r.links.relayed); + join(one); + } + if (r.links.direct > 0) { + l10n::Fmt(one, sizeof(one), l10n::T("%d direct"), r.links.direct); + join(one); + } + if (r.links.lan > 0) { + l10n::Fmt(one, sizeof(one), l10n::T("%d LAN"), r.links.lan); + join(one); + } return parts.empty() ? std::string() : " -- " + parts; } @@ -169,7 +185,7 @@ bool BuildDetails(void* parent) { // count changes with the selection and an auto-sized box would have slid the pane below it // on every click; a fill slot cannot do that, since its height comes from the column, not // its content. - void* col = SectionBody(parent, kPanel, L"Server info:", 1.f, 0.f); + void* col = SectionBody(parent, kPanel, Wide(l10n::T("Server info:")).c_str(), 1.f, 0.f); if (!col) return false; // The name is the panel's own subject and gets the emphasis the row gives it. g_dName = Line{DetailLine(col, 20, kText), {}}; @@ -236,7 +252,7 @@ void Sync(bool force) { // Empty is a state with its own sentence, not five blank lines: a panel of empty labels // reads as a panel that failed to load. g_dName.SetColor(kDim); - g_dName.Set("Select a server"); + g_dName.Set(l10n::T("Select a server")); g_dWorld.Set(""); g_dVersion.Set(""); g_dPlayers.Set(""); @@ -245,7 +261,10 @@ void Sync(bool force) { } else { g_dName.SetColor(kText); g_dName.Set(r.name); - g_dWorld.Set("World: " + (r.world.empty() ? std::string("(unnamed)") : r.world)); + char world[256]; + l10n::Fmt(world, sizeof(world), l10n::T("World: %s"), + r.world.empty() ? l10n::T("(unnamed)") : r.world.c_str()); + g_dWorld.Set(world); // The version line says which side must update, because a bare mismatch leaves the player // with nothing to do. The pair is compared exactly (the join gate is byte equality per @@ -256,27 +275,47 @@ void Sync(bool force) { const bool gameBad = !r.game.empty() && r.game != sm::GameTarget(); const int ourProto = static_cast(coop::net::kProtocolVersion); const bool protoBad = r.proto > 0 && r.proto != ourProto; - std::string ver = "Version: "; - ver += r.game.empty() ? (r.version.empty() ? std::string("unknown") : r.version) - : r.game; - if (r.proto > 0) ver += " b" + std::to_string(r.proto); + // The game target, build number and master's version are identifiers; the word "unknown" is not. + const std::string which = + r.game.empty() ? (r.version.empty() ? std::string(l10n::T("unknown")) : r.version) + : r.game; + const std::string build = r.proto > 0 ? " b" + std::to_string(r.proto) : std::string(); + // One msgid per verdict: the clause is a sentence's end and moves with the language. + char ver[256]; if (gameBad) { - ver += " -- built for a different game version"; + l10n::Fmt(ver, sizeof(ver), + l10n::T("Version: %1$s%2$s -- built for a different game version"), + which.c_str(), build.c_str()); + } else if (protoBad && r.proto < ourProto) { + l10n::Fmt(ver, sizeof(ver), l10n::T("Version: %1$s%2$s -- the host must update"), + which.c_str(), build.c_str()); } else if (protoBad) { - ver += r.proto < ourProto ? " -- the host must update" - : " -- you must update"; + l10n::Fmt(ver, sizeof(ver), l10n::T("Version: %1$s%2$s -- you must update"), + which.c_str(), build.c_str()); + } else { + l10n::Fmt(ver, sizeof(ver), l10n::T("Version: %1$s%2$s"), which.c_str(), + build.c_str()); } g_dVersion.SetColor(gameBad || protoBad ? kBad : kDim); g_dVersion.Set(ver); - g_dPlayers.Set("Players: " + std::to_string(r.playersCur) + "/" + - std::to_string(r.playersMax)); + char players[256]; + l10n::Fmt(players, sizeof(players), l10n::T("Players: %d/%d"), r.playersCur, + r.playersMax); + g_dPlayers.Set(players); // The direct flag is parsed off the wire and had no reader on this screen: a direct host is // port-forwarded UDP, an automatic host is brokered peer-to-peer, the difference between a // NAT that may need to cooperate and one that does not, worth one word. - g_dConn.Set(std::string("Connection: ") + (r.direct ? "direct" : "p2p") + LinksTail(r) + - (r.locked ? " (locked)" : "")); - g_dSeen.Set("Last seen: " + Sec(rows::AgeNowSec(r))); + char head[256]; + l10n::Fmt(head, sizeof(head), l10n::T("Connection: %s"), + r.direct ? l10n::T("direct") : l10n::T("p2p")); + std::string conn = head; + conn += LinksTail(r); + if (r.locked) { conn += " "; conn += l10n::T("(locked)"); } + g_dConn.Set(conn); + char seen[256]; + l10n::Fmt(seen, sizeof(seen), l10n::T("Last seen: %ds ago"), rows::AgeNowSec(r)); + g_dSeen.Set(seen); } // The status. @@ -285,18 +324,30 @@ void Sync(bool force) { // Nothing has answered for this list yet -- the browser just opened, or another master // was chosen -- so there is neither a count nor an age to state, and dating the empty // list would claim a fetch that never happened. The alarm below speaks if it stays silent. - g_sCount.Set("Waiting for the " + slots::Selected().label + " list..."); + char waiting[256]; + l10n::Fmt(waiting, sizeof(waiting), l10n::T("Waiting for the %s list..."), + slots::Selected().label.c_str()); + g_sCount.Set(waiting); g_sFresh.Set(std::string()); } else { const int count = rows::Count(); const uint64_t sinceMs = rows::MsSinceFetch(); - g_sCount.Set(std::to_string(count) + (count == 1 ? " server" : " servers")); + char countLine[256]; + l10n::Fmt(countLine, sizeof(countLine), l10n::Tn("%llu server", "%llu servers", + static_cast(count)), static_cast(count)); + g_sCount.Set(countLine); // Always say when, and say just now for the sub-second case rather than dropping the // clause: written only for a positive elapsed time, the line vanished on a capture taken // in the same millisecond as a fetch, and a clause that appears and disappears is a // worse instrument than one that is always there. - g_sFresh.Set(std::string("updated ") + - (sinceMs < 1000 ? "just now" : Sec(static_cast(sinceMs / 1000u)))); + if (sinceMs < 1000) { + g_sFresh.Set(l10n::T("updated just now")); + } else { + char fresh[256]; + l10n::Fmt(fresh, sizeof(fresh), l10n::T("updated %ds ago"), + static_cast(sinceMs / 1000u)); + g_sFresh.Set(fresh); + } } // The alarm keys on consecutive failures, never on a clock: two failed attempts means the @@ -307,11 +358,19 @@ void Sync(bool force) { // the answer when one master is unreachable from where the player sits, and they are the one // control on this screen a player in that state would not think to try. const int fails = sm::FetchFailures(); - std::string alarm; + char alarm[320]; + alarm[0] = '\0'; if (fails >= 2) { - alarm = "Cannot reach the " + slots::Selected().label + " server list. " + - (slots::List().size() > 1 ? "Try another list above, or check your connection." - : "Check your connection."); + const std::string listName = slots::Selected().label; + if (slots::List().size() > 1) { + l10n::Fmt(alarm, sizeof(alarm), + l10n::T("Cannot reach the %s server list. Try another list above, or check " + "your connection."), listName.c_str()); + } else { + l10n::Fmt(alarm, sizeof(alarm), + l10n::T("Cannot reach the %s server list. Check your connection."), + listName.c_str()); + } } g_sAlarm.Set(alarm); @@ -325,7 +384,9 @@ void Sync(bool force) { // The name everyone else will see, phrased as an answer rather than as a claim about what // the player is doing (see the status pane's note). The Change name button edits exactly // this value. - g_sNick.Set("Your name: " + sm::Nickname()); + char nick[256]; + l10n::Fmt(nick, sizeof(nick), l10n::T("Your name: %s"), sm::Nickname().c_str()); + g_sNick.Set(nick); } } // namespace ui::server_browser_panels diff --git a/src/votv-coop/src/ui/server_browser_rows.cpp b/src/votv-coop/src/ui/server_browser_rows.cpp index 96738925a..c968d6109 100644 --- a/src/votv-coop/src/ui/server_browser_rows.cpp +++ b/src/votv-coop/src/ui/server_browser_rows.cpp @@ -3,6 +3,8 @@ #include "ui/server_browser_rows.h" +#include "l10n/l10n.h" + #include "coop/net/protocol.h" // kProtocolVersion -- the version-cell mismatch tint #include "coop/session/session_manager.h" #include "coop/text/utf8_codec.h" // the one owner of text encoding @@ -503,12 +505,16 @@ void Sync() { // yellows on hover nor fades on a stale row (four more dispatches per painted row is not // worth it). ESlateVisibility: Visible=0, Collapsed=1, Hidden=2. if (rp.lock) E::SetWidgetVisibility(rp.lock, r.locked ? 0 : 2); - SetRowText(rp.text[kCellName], isOwn ? r.name + " (your server)" : r.name); + // The tag is its own msgid: a name has no place in a translator's sentence, and a long name + // must never cut the tag off. + std::string nameText = r.name; + if (isOwn) { nameText += " "; nameText += l10n::T("(your server)"); } + SetRowText(rp.text[kCellName], nameText); SetRowText(rp.text[kCellFlag], MismatchMark(r)); SetRowText(rp.text[kCellLink], ui::link_format::LobbyLinkLabel(r.link)); - SetRowText(rp.text[kCellPlayers], - "Players: " + std::to_string(r.playersCur) + "/" + - std::to_string(r.playersMax)); + char players[256]; + l10n::Fmt(players, sizeof(players), l10n::T("Players: %d/%d"), r.playersCur, r.playersMax); + SetRowText(rp.text[kCellPlayers], players); // Selection is keyed on the lobby, not the row index: the set churns and one host leaving // shifts every row after it, so an index-keyed selection would move to whatever landed // there. Read from r.lobbyId, not RowIsSelected(i): the assign above cleared g_rowIds and From f7484c9e7223cc52f9ebf9797a96b8bf41dc765c Mon Sep 17 00:00:00 2001 From: pelmentor Date: Sat, 10 Oct 2026 06:41:45 +0600 Subject: [PATCH 086/306] [ui] join screens, HUD and overlay panels in the player's language The loading screen, the end-of-session dialog, the scoreboard, the console hint, the voice window and the config review panel look every drawn string up through l10n. Sentences with values are one msgid through Fmt, the duplicate-setting count is a plural pair, the animated dots follow the looked-up sentence, buttons and menu items carry Label ids, and lone button words take the hud context. Left as they are: window titles and ## ids, value formats handed to sliders, log lines and echoed command text, the (?) marks, ini keys and file names (keys as %s arguments), row reasons and the end-reason sentences composed in other files. hud.cpp and join_curtain.cpp draw no literal of their own. Co-Authored-By: Claude Opus 5.5 --- src/votv-coop/src/ui/config_review_panel.cpp | 196 +++++++++++-------- src/votv-coop/src/ui/console.cpp | 4 +- src/votv-coop/src/ui/end_reason_dialog.cpp | 12 +- src/votv-coop/src/ui/loading_screen.cpp | 63 +++--- src/votv-coop/src/ui/scoreboard.cpp | 121 +++++++----- src/votv-coop/src/ui/voice_panel.cpp | 117 ++++++----- 6 files changed, 305 insertions(+), 208 deletions(-) diff --git a/src/votv-coop/src/ui/config_review_panel.cpp b/src/votv-coop/src/ui/config_review_panel.cpp index 3206a32cf..30f42ec43 100644 --- a/src/votv-coop/src/ui/config_review_panel.cpp +++ b/src/votv-coop/src/ui/config_review_panel.cpp @@ -5,6 +5,7 @@ #include "coop/config/config.h" // why multivoid.ini could not be read #include "coop/config/config_registry.h" // which settings refuse instead of falling back #include "coop/config/config_review.h" +#include "l10n/l10n.h" #include "ui/menu_sfx.h" #include "ui/scale.h" @@ -47,11 +48,11 @@ const std::string& FailClosedNames() { const char* TypeHeading(CR::Row::Type t) { switch (t) { - case CR::Row::Type::Rejected: return "Values that could not be used"; - case CR::Row::Type::Unknown: return "Unknown settings (nothing reads these)"; - case CR::Row::Type::DuplicateDormant: return "Duplicate settings with different values"; - case CR::Row::Type::IdentityNotDurable: return "Body skin not saved"; - case CR::Row::Type::IniUnreadable: return "multivoid.ini could not be read"; + case CR::Row::Type::Rejected: return l10n::T("Values that could not be used"); + case CR::Row::Type::Unknown: return l10n::T("Unknown settings (nothing reads these)"); + case CR::Row::Type::DuplicateDormant: return l10n::T("Duplicate settings with different values"); + case CR::Row::Type::IdentityNotDurable: return l10n::T("Body skin not saved"); + case CR::Row::Type::IniUnreadable: return l10n::T("multivoid.ini could not be read"); } return "?"; } @@ -67,21 +68,26 @@ void RenderRowsOfType(const std::vector& rows, CR::Row::Type type) { ImGui::PopStyleColor(); headed = true; } + char line[1024]; // one formatted sentence of the row; the row's own words are long switch (type) { case CR::Row::Type::Rejected: ImGui::Bullet(); // What the value leads to: a set env twin shadows an ini line whatever it holds; a // fail-closed row has no default standing in, so what it governs is refused. if (!r.overriddenBy.empty()) - ImGui::TextWrapped("%s = '%s' (%s) -- %s; not in use, %s overrides it.", - r.key.c_str(), r.value.c_str(), r.origin.c_str(), - r.reason.c_str(), r.overriddenBy.c_str()); + l10n::Fmt(line, sizeof(line), + l10n::T("%1$s = '%2$s' (%3$s) -- %4$s; not in use, %5$s overrides it."), + r.key.c_str(), r.value.c_str(), r.origin.c_str(), r.reason.c_str(), + r.overriddenBy.c_str()); + else if (r.failClosed) + l10n::Fmt(line, sizeof(line), + l10n::T("%1$s = '%2$s' (%3$s) -- %4$s; nothing it governs starts until it is fixed."), + r.key.c_str(), r.value.c_str(), r.origin.c_str(), r.reason.c_str()); else - ImGui::TextWrapped("%s = '%s' (%s) -- %s; %s", r.key.c_str(), r.value.c_str(), - r.origin.c_str(), r.reason.c_str(), - r.failClosed - ? "nothing it governs starts until it is fixed." - : "the built-in default is used."); + l10n::Fmt(line, sizeof(line), + l10n::T("%1$s = '%2$s' (%3$s) -- %4$s; the built-in default is used."), + r.key.c_str(), r.value.c_str(), r.origin.c_str(), r.reason.c_str()); + ImGui::TextWrapped("%s", line); break; case CR::Row::Type::Unknown: ImGui::Bullet(); @@ -92,23 +98,34 @@ void RenderRowsOfType(const std::vector& rows, CR::Row::Type type) { // screenshot people paste into a bug report. ImGui::TextWrapped("%s -- %s", r.key.c_str(), r.reason.c_str()); } else { - ImGui::TextWrapped("%s = '%s' -- not a known setting (typo, or a retired key).", - r.key.c_str(), r.value.c_str()); + l10n::Fmt(line, sizeof(line), + l10n::T("%1$s = '%2$s' -- not a known setting (typo, or a retired key)."), + r.key.c_str(), r.value.c_str()); + ImGui::TextWrapped("%s", line); } break; case CR::Row::Type::DuplicateDormant: { ImGui::Bullet(); - ImGui::TextWrapped("%s appears %d times with different values; the FIRST line " - "(line %d) wins. Pick which one to keep:", - r.key.c_str(), static_cast(r.dupLines.size()), - r.dupLines.empty() ? 0 : r.dupLines[0].lineNo); + const auto times = static_cast(r.dupLines.size()); + l10n::Fmt(line, sizeof(line), + l10n::Tn("%1$s appears %2$llu time with different values; the FIRST line " + "(line %3$d) wins. Pick which one to keep:", + "%1$s appears %2$llu times with different values; the FIRST line " + "(line %3$d) wins. Pick which one to keep:", + times), + r.key.c_str(), times, r.dupLines.empty() ? 0 : r.dupLines[0].lineNo); + ImGui::TextWrapped("%s", line); if (r.identityKey) - ImGui::TextWrapped(" This key holds your body skin -- if the winning " - "value is not the one you expect, keep the other line."); + ImGui::TextWrapped(" %s", l10n::T("This key holds your body skin -- if the winning " + "value is not the one you expect, keep the other line.")); for (const auto& dl : r.dupLines) { - char btn[192]; - std::snprintf(btn, sizeof(btn), "Keep line %d: %s###cfgrev_%s_%d", - dl.lineNo, dl.value.c_str(), r.key.c_str(), dl.lineNo); + // The words are looked up; the id after "###" stays the runtime pair that names + // this key's line, so a translation never moves it. + char keep[256]; + l10n::Fmt(keep, sizeof(keep), l10n::T("Keep line %d: %s"), dl.lineNo, dl.value.c_str()); + char btn[320]; + std::snprintf(btn, sizeof(btn), "%s###cfgrev_%s_%d", + keep, r.key.c_str(), dl.lineNo); ImGui::SetCursorPosX(ImGui::GetCursorPosX() + S(24.f)); // Correlated by VALUE, not the shown line number: // the snapshot ages while the panel sits on screen. @@ -119,37 +136,46 @@ void RenderRowsOfType(const std::vector& rows, CR::Row::Type type) { } case CR::Row::Type::IdentityNotDurable: ImGui::Bullet(); - ImGui::TextWrapped("Your body skin (player_skin) could not be saved to " - "multivoid.ini this launch -- it is temporary for this " - "session. Fix whatever keeps the file from being read or " - "saved (a lock from an editor or antivirus, permissions, or " - "a file saved as UTF-16); nothing is lost once a " - "launch can save again."); + l10n::Fmt(line, sizeof(line), + l10n::T("Your body skin (%s) could not be saved to " + "multivoid.ini this launch -- it is temporary for this " + "session. Fix whatever keeps the file from being read or " + "saved (a lock from an editor or antivirus, permissions, or " + "a file saved as UTF-16); nothing is lost once a " + "launch can save again."), + "player_skin"); + ImGui::TextWrapped("%s", line); break; case CR::Row::Type::IniUnreadable: ImGui::Bullet(); // From the row, not the live fault: the row says what the sweep saw, and a later // read, clean or locked, must not rewrite why it was raised. if (!r.unreadableNow) { - ImGui::TextWrapped("multivoid.ini could not be read earlier this launch (%s) " - "and reads now. Settings read while it could not used " - "their defaults; restart so every setting reads the file.", + ImGui::TextWrapped("%s", r.fault == coop::config::IniFault::NotText - ? "a zero byte or a UTF-16 byte-order mark" - : "locked or failing"); + ? l10n::T("multivoid.ini could not be read earlier this launch " + "(a zero byte or a UTF-16 byte-order mark) and reads now. " + "Settings read while it could not used their defaults; " + "restart so every setting reads the file.") + : l10n::T("multivoid.ini could not be read earlier this launch " + "(locked or failing) and reads now. " + "Settings read while it could not used their defaults; " + "restart so every setting reads the file.")); break; } if (r.fault == coop::config::IniFault::NotText) - ImGui::TextWrapped("multivoid.ini holds a zero byte or starts with a UTF-16 " - "byte-order mark, as a file saved as UTF-16 (\"Unicode\") " - "does. Save it again as UTF-8."); + ImGui::TextWrapped("%s", l10n::T("multivoid.ini holds a zero byte or starts with a UTF-16 " + "byte-order mark, as a file saved as UTF-16 (\"Unicode\") " + "does. Save it again as UTF-8.")); else - ImGui::TextWrapped("multivoid.ini exists but could not be read (locked or " - "failing)."); - ImGui::TextWrapped("This launch runs on environment overrides and built-in " - "defaults; the settings that refuse instead of falling back " - "(%s) refuse what they govern until the file can be read.", - FailClosedNames().c_str()); + ImGui::TextWrapped("%s", l10n::T("multivoid.ini exists but could not be read (locked or " + "failing).")); + l10n::Fmt(line, sizeof(line), + l10n::T("This launch runs on environment overrides and built-in " + "defaults; the settings that refuse instead of falling back " + "(%s) refuse what they govern until the file can be read."), + FailClosedNames().c_str()); + ImGui::TextWrapped("%s", line); break; } } @@ -181,18 +207,21 @@ void Render() { ImGuiWindowFlags_NoTitleBar; if (ImGui::Begin("###coop_config_review", nullptr, flags)) { ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.55f, 0.85f, 1.0f, 1.0f)); - ImGui::TextUnformatted("MULTIVOID SETTINGS CHECK"); + ImGui::TextUnformatted(l10n::T("MULTIVOID SETTINGS CHECK")); ImGui::PopStyleColor(); ImGui::SameLine(); ImGui::TextDisabled("(?)"); - if (ImGui::IsItemHovered()) - ImGui::SetTooltip( - "multivoid.ini was checked against the known settings at launch.\n" - "Nothing was changed automatically -- broken values fall back to\n" - "their defaults for this session, except the settings that refuse\n" - "instead (%s). The buttons below edit the file only when you click\n" - "them. This notice returns next launch while anything is still off.", - FailClosedNames().c_str()); + if (ImGui::IsItemHovered()) { + char tip[1024]; + l10n::Fmt(tip, sizeof(tip), + l10n::T("multivoid.ini was checked against the known settings at launch.\n" + "Nothing was changed automatically -- broken values fall back to\n" + "their defaults for this session, except the settings that refuse\n" + "instead (%s). The buttons below edit the file only when you click\n" + "them. This notice returns next launch while anything is still off."), + FailClosedNames().c_str()); + ImGui::SetTooltip("%s", tip); + } ImGui::Separator(); RenderRowsOfType(rows, CR::Row::Type::IniUnreadable); @@ -204,51 +233,58 @@ void Render() { ImGui::Spacing(); ImGui::Separator(); ImGui::Spacing(); - if (ui::menu_sfx::Button("Tidy up multivoid.ini###cfgrev_reformat", + if (ui::menu_sfx::Button(l10n::Label(l10n::T("Tidy up multivoid.ini"), "cfgrev_reformat"), ImVec2(S(220.f), S(30.f)))) { g_tidyOutcome = CR::ReformatNow(); g_tidyPressed = true; } ImGui::SameLine(); ImGui::TextDisabled("(?)"); - if (ImGui::IsItemHovered()) - ImGui::SetTooltip( - "Rewrites multivoid.ini in the standard layout: [net] first, [dev]\n" - "last, each setting under its section, exact-duplicate lines merged.\n" - "Unknown settings and invalid values are commented out (kept in the\n" - "file, just disabled) -- that resolves their warnings above. An\n" - "invalid value of a setting that refuses instead (%s) is kept as\n" - "it is: disabling it would switch that setting to its default.\n" - "Conflicting duplicates are NEVER auto-resolved -- use the keep-line\n" - "buttons above. Your comments travel with their settings.", - FailClosedNames().c_str()); + if (ImGui::IsItemHovered()) { + char tip[1024]; + l10n::Fmt(tip, sizeof(tip), + l10n::T("Rewrites multivoid.ini in the standard layout: [net] first, [dev]\n" + "last, each setting under its section, exact-duplicate lines merged.\n" + "Unknown settings and invalid values are commented out (kept in the\n" + "file, just disabled) -- that resolves their warnings above. An\n" + "invalid value of a setting that refuses instead (%s) is kept as\n" + "it is: disabling it would switch that setting to its default.\n" + "Conflicting duplicates are NEVER auto-resolved -- use the keep-line\n" + "buttons above. Your comments travel with their settings."), + FailClosedNames().c_str()); + ImGui::SetTooltip("%s", tip); + } ImGui::SameLine(); - if (ui::menu_sfx::Button("Dismiss###cfgrev_dismiss", ImVec2(S(120.f), S(30.f)))) + if (ui::menu_sfx::Button(l10n::Label(l10n::Tc("hud", "Dismiss"), "cfgrev_dismiss"), + ImVec2(S(120.f), S(30.f)))) CR::Dismiss(); if (g_tidyPressed) { ImGui::Spacing(); if (!g_tidyOutcome.ok) { ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.0f, 0.45f, 0.4f, 1.0f)); if (g_tidyOutcome.fault == coop::config::IniFault::NotText) - ImGui::TextWrapped("Could not rewrite multivoid.ini: it holds a zero byte or a " - "UTF-16 byte-order mark -- nothing was changed. Save it " - "again as UTF-8 and press Tidy up again."); + ImGui::TextWrapped("%s", l10n::T("Could not rewrite multivoid.ini: it holds a zero byte or a " + "UTF-16 byte-order mark -- nothing was changed. Save it " + "again as UTF-8 and press Tidy up again.")); else if (g_tidyOutcome.fault == coop::config::IniFault::ReadFailed) - ImGui::TextWrapped("Could not rewrite multivoid.ini (locked or failing) -- " - "nothing was changed. Close whatever holds the file and " - "press Tidy up again."); + ImGui::TextWrapped("%s", l10n::T("Could not rewrite multivoid.ini (locked or failing) -- " + "nothing was changed. Close whatever holds the file and " + "press Tidy up again.")); else - ImGui::TextWrapped("Could not rewrite multivoid.ini (it is missing, or the " - "write failed) -- nothing was changed."); + ImGui::TextWrapped("%s", l10n::T("Could not rewrite multivoid.ini (it is missing, or the " + "write failed) -- nothing was changed.")); ImGui::PopStyleColor(); } else { ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.5f, 0.9f, 0.55f, 1.0f)); - ImGui::TextWrapped("Tidied: %d duplicate line(s) merged, %d setting(s) placed " - "under their sections, %d unknown/invalid line(s) commented " - "out. Anything still listed above needs a choice or can't " - "be fixed from the file.", - g_tidyOutcome.collapsed, g_tidyOutcome.placed, - g_tidyOutcome.retired); + // Three counts in one sentence: Tn takes one, so the "(s)" forms stay. + char tidied[512]; + l10n::Fmt(tidied, sizeof(tidied), + l10n::T("Tidied: %d duplicate line(s) merged, %d setting(s) placed " + "under their sections, %d unknown/invalid line(s) commented " + "out. Anything still listed above needs a choice or can't " + "be fixed from the file."), + g_tidyOutcome.collapsed, g_tidyOutcome.placed, g_tidyOutcome.retired); + ImGui::TextWrapped("%s", tidied); ImGui::PopStyleColor(); } } diff --git a/src/votv-coop/src/ui/console.cpp b/src/votv-coop/src/ui/console.cpp index 7cfc8f1aa..05c058e4e 100644 --- a/src/votv-coop/src/ui/console.cpp +++ b/src/votv-coop/src/ui/console.cpp @@ -3,6 +3,7 @@ #include "ui/console.h" #include "coop/session/join_progress.h" +#include "l10n/l10n.h" #include "ui/scale.h" #include "ue_wrap/core/log.h" @@ -171,7 +172,8 @@ void Render() { return 0; } }; - if (ImGui::InputTextWithHint("##cmd", "type a command -- 'help'", g_input, sizeof(g_input), + if (ImGui::InputTextWithHint("##cmd", l10n::T("type a command -- 'help'"), g_input, + sizeof(g_input), ImGuiInputTextFlags_EnterReturnsTrue | ImGuiInputTextFlags_CallbackHistory, &Hist::Cb)) { if (g_input[0] != '\0' && (sHistory.empty() || sHistory.back() != g_input)) { diff --git a/src/votv-coop/src/ui/end_reason_dialog.cpp b/src/votv-coop/src/ui/end_reason_dialog.cpp index 15c4e14b1..c082ab9f3 100644 --- a/src/votv-coop/src/ui/end_reason_dialog.cpp +++ b/src/votv-coop/src/ui/end_reason_dialog.cpp @@ -4,7 +4,8 @@ #include "coop/net/end_reason.h" #include "coop/session/join_progress.h" // PeekNotice / ClearNotice (the notice owner) -#include "ui/menu_sfx.h" // native VOTV button click + rollover sounds +#include "l10n/l10n.h" +#include "ui/menu_sfx.h" // native VOTV button click + rollover sounds #include "ui/scale.h" #include "imgui.h" @@ -53,7 +54,7 @@ void Render() { if (ImGui::Begin("###coop_end_reason", nullptr, flags)) { ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.0f, 0.55f, 0.45f, 1.0f)); // A join that never completed could not connect; a link that ended after it disconnected. - ImGui::TextUnformatted(notice.afterJoin ? "DISCONNECTED" : "COULD NOT CONNECT"); + ImGui::TextUnformatted(notice.afterJoin ? l10n::T("DISCONNECTED") : l10n::T("COULD NOT CONNECT")); ImGui::PopStyleColor(); ImGui::Spacing(); ImGui::Separator(); @@ -69,8 +70,8 @@ void Render() { } ImGui::Spacing(); // The stable code, so a report names the site without a log. - char code[96]; - std::snprintf(code, sizeof(code), "Code %s -- include it in a bug report", info.id); + char code[256]; + l10n::Fmt(code, sizeof(code), l10n::T("Code %s -- include it in a bug report"), info.id); ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.58f, 0.56f, 0.58f, 1.0f)); ImGui::TextWrapped("%s", code); ImGui::PopStyleColor(); @@ -80,7 +81,8 @@ void Render() { const float bw = S(120.0f); ImGui::SetCursorPosX((ImGui::GetWindowWidth() - bw) * 0.5f); - if (ui::menu_sfx::Button("OK###coop_er_ok", ImVec2(bw, S(30.0f)))) { + if (ui::menu_sfx::Button(l10n::Label(l10n::Tc("hud", "OK"), "coop_er_ok"), + ImVec2(bw, S(30.0f)))) { coop::join_progress::ClearNotice(); // acknowledge -> hide next frame } } diff --git a/src/votv-coop/src/ui/loading_screen.cpp b/src/votv-coop/src/ui/loading_screen.cpp index a8bc9dcc2..67750cb20 100644 --- a/src/votv-coop/src/ui/loading_screen.cpp +++ b/src/votv-coop/src/ui/loading_screen.cpp @@ -4,6 +4,7 @@ #include "coop/net/protocol.h" // HostJoinPhase -- the beacon's phase, as the View carries it #include "coop/session/join_progress.h" +#include "l10n/l10n.h" #include "ui/scale.h" #include "imgui.h" @@ -73,66 +74,73 @@ void Render() { if (ImGui::Begin("###coop_loading", nullptr, flags)) { ImGui::SetWindowFontScale(1.25f); ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.92f, 0.94f, 0.98f, 1.0f)); - CenteredText("MULTIPLAYER"); + CenteredText(l10n::T("MULTIPLAYER")); ImGui::PopStyleColor(); ImGui::SetWindowFontScale(1.0f); ImGui::Dummy(ImVec2(0.0f, S(8.0f))); // Status line: the phase, and inside Connecting the stage, each with its own sentence, so // a joiner can tell which of the six steps before the first download byte it is waiting in. - char status[160]; + // The sentence is looked up and formatted without the animated trailing dots, which are + // not language: they follow it, so a translation never carries or reorders them. + char body[256]; + const char* text = body; + bool dotted = true; const int dots = static_cast(ImGui::GetTime() * 2.0) % 4; char d[4] = {0}; for (int i = 0; i < dots; ++i) d[i] = '.'; - const char* host = v.host.empty() ? "the host" : v.host.c_str(); + const char* host = v.host.empty() ? l10n::T("the host") : v.host.c_str(); if (v.mode == jp::Mode::Host) { // HOST boot (the Host-Game flow): loading our OWN world before we host. if (!v.host.empty()) - std::snprintf(status, sizeof(status), "Starting your server -- loading %s%s", v.host.c_str(), d); + l10n::Fmt(body, sizeof(body), l10n::T("Starting your server -- loading %s"), v.host.c_str()); else - std::snprintf(status, sizeof(status), "Starting your server%s", d); + text = l10n::T("Starting your server"); } else if (v.phase == jp::Phase::Connecting) { switch (v.stage) { case jp::Stage::FindingHost: - std::snprintf(status, sizeof(status), "Looking up %s%s", host, d); + l10n::Fmt(body, sizeof(body), l10n::T("Looking up %s"), host); break; case jp::Stage::FindingRoute: - std::snprintf(status, sizeof(status), "Finding a route to %s%s", host, d); + l10n::Fmt(body, sizeof(body), l10n::T("Finding a route to %s"), host); break; case jp::Stage::ProvingIdentity: - std::snprintf(status, sizeof(status), "Exchanging identities with the host%s", d); + text = l10n::T("Exchanging identities with the host"); break; case jp::Stage::Joining: - std::snprintf(status, sizeof(status), "Joining %s%s", host, d); + l10n::Fmt(body, sizeof(body), l10n::T("Joining %s"), host); break; case jp::Stage::WaitingForWorld: - std::snprintf(status, sizeof(status), "Waiting for the host to capture its world%s", d); + text = l10n::T("Waiting for the host to capture its world"); break; default: // Dialing, or no stage reported yet - std::snprintf(status, sizeof(status), "Connecting to %s%s", host, d); + l10n::Fmt(body, sizeof(body), l10n::T("Connecting to %s"), host); break; } } else if (v.phase == jp::Phase::Downloading) { // Say what is happening AND that it is normal. On an internet link this // phase is ~17 s for a mature world and it used to read "Connecting...", // which is what a player interprets as "hung". - std::snprintf(status, sizeof(status), "Downloading the host's world%s", d); + text = l10n::T("Downloading the host's world"); } else if (v.phase == jp::Phase::LoadingWorld) { // The longest stage of all (30-60 s, 120 s capped) and NOTHING reports // progress out of the engine's load -- so it gets the marquee, which at // least animates, and a label that is true. - std::snprintf(status, sizeof(status), "Loading the world%s", d); + text = l10n::T("Loading the world"); } else if (v.phase == jp::Phase::AwaitingWorldStream) { // The world is up and the wait belongs to the host now. Say which side is working, and // say the one thing the host reports that the player cannot otherwise see: that it is // holding the stream until its own world settles. if (v.hostPhase == static_cast(coop::net::HostJoinPhase::SnapshotDeferred)) - std::snprintf(status, sizeof(status), "The host is settling its world%s", d); + text = l10n::T("The host is settling its world"); else - std::snprintf(status, sizeof(status), "Waiting for the host's world%s", d); + text = l10n::T("Waiting for the host's world"); } else { - std::snprintf(status, sizeof(status), "Receiving world from the host"); + text = l10n::T("Receiving world from the host"); + dotted = false; } + char status[256]; + l10n::Fmt(status, sizeof(status), "%s%s", text, dotted ? d : ""); ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.80f, 0.84f, 0.90f, 1.0f)); CenteredText(status); ImGui::PopStyleColor(); @@ -141,9 +149,9 @@ void Render() { // laid out, so the panel does not jump when the number appears. ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.55f, 0.60f, 0.68f, 1.0f)); if (v.stageMs >= 3000) { - char since[48]; - std::snprintf(since, sizeof(since), "%llu s in this step", - static_cast(v.stageMs / 1000)); + char since[256]; + l10n::Fmt(since, sizeof(since), l10n::T("%llu s in this step"), + static_cast(v.stageMs / 1000)); CenteredText(since); } else { ImGui::Dummy(ImVec2(0.0f, ImGui::GetTextLineHeight())); @@ -154,20 +162,20 @@ void Render() { // Progress bar. if (v.phase == jp::Phase::Downloading && v.totalBytes > 0) { const float frac = static_cast(v.doneBytes) / static_cast(v.totalBytes); - char overlay[64]; + char overlay[256]; // MB, one decimal -- bytes are unreadable at this size and a percentage // alone does not tell the player the transfer is large by nature. - std::snprintf(overlay, sizeof(overlay), "%.1f / %.1f MB", - static_cast(v.doneBytes) / (1024.0 * 1024.0), - static_cast(v.totalBytes) / (1024.0 * 1024.0)); + l10n::Fmt(overlay, sizeof(overlay), l10n::T("%.1f / %.1f MB"), + static_cast(v.doneBytes) / (1024.0 * 1024.0), + static_cast(v.totalBytes) / (1024.0 * 1024.0)); ImGui::PushStyleColor(ImGuiCol_PlotHistogram, ImVec4(0.36f, 0.59f, 0.82f, 1.0f)); ImGui::PushStyleColor(ImGuiCol_FrameBg, ImVec4(0.10f, 0.12f, 0.16f, 1.0f)); ImGui::ProgressBar(frac > 1.0f ? 1.0f : frac, ImVec2(barW, 0.0f), overlay); ImGui::PopStyleColor(2); } else if (v.phase == jp::Phase::Receiving && v.total > 0) { const float frac = static_cast(v.applied) / static_cast(v.total); - char overlay[64]; - std::snprintf(overlay, sizeof(overlay), "%u / %u objects", v.applied, v.total); + char overlay[256]; + l10n::Fmt(overlay, sizeof(overlay), l10n::T("%u / %u objects"), v.applied, v.total); ImGui::PushStyleColor(ImGuiCol_PlotHistogram, ImVec4(0.36f, 0.59f, 0.82f, 1.0f)); ImGui::PushStyleColor(ImGuiCol_FrameBg, ImVec4(0.10f, 0.12f, 0.16f, 1.0f)); ImGui::ProgressBar(frac > 1.0f ? 1.0f : frac, ImVec2(barW, 0.0f), overlay); @@ -182,14 +190,15 @@ void Render() { // a cancel here used to (via the client abort path) Stop the host session the // instant it started. The host just waits -- a new game can take a moment. ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.60f, 0.64f, 0.72f, 1.0f)); - CenteredText("A new game can take a moment to load"); + CenteredText(l10n::T("A new game can take a moment to load")); ImGui::PopStyleColor(); } else { // Cancel: abort the CLIENT join (the harness drains the request -> Stop + // reopen browser; a session-death flee then lands us at the main menu). const float btnW = S(120.0f); ImGui::SetCursorPosX(ImGui::GetCursorPosX() + (ImGui::GetContentRegionAvail().x - btnW) * 0.5f); - if (ImGui::Button("Cancel", ImVec2(btnW, 0.0f))) jp::RequestCancel(); + if (ImGui::Button(l10n::Label(l10n::Tc("hud", "Cancel"), "cancel"), ImVec2(btnW, 0.0f))) + jp::RequestCancel(); } } ImGui::End(); diff --git a/src/votv-coop/src/ui/scoreboard.cpp b/src/votv-coop/src/ui/scoreboard.cpp index edcd10fe4..110cf5d88 100644 --- a/src/votv-coop/src/ui/scoreboard.cpp +++ b/src/votv-coop/src/ui/scoreboard.cpp @@ -7,6 +7,7 @@ #include "coop/player/nick_color.h" #include "coop/player/roster.h" #include "coop/voice/voice_chat.h" +#include "l10n/l10n.h" #include "ui/link_format.h" #include "ui/scale.h" #include "ui/voice_icons.h" @@ -50,17 +51,25 @@ void StatusDot(bool connected) { // On the host, a row's nick hovered shows the player id a permission file is named by. void IdTooltip(const coop::roster::Row& r, bool host) { if (!host || r.playerId[0] == '\0' || !ImGui::IsItemHovered()) return; - ImGui::SetTooltip("%s %s", r.isLocal ? "Your player id" : "Player id", r.playerId); + char tip[256]; + l10n::Fmt(tip, sizeof(tip), r.isLocal ? l10n::T("Your player id %s") : l10n::T("Player id %s"), + r.playerId); + ImGui::SetTooltip("%s", tip); } -// A menu item that is off, with the reason on hover, while the person has no proved id yet. -bool IdMenuItem(const char* label, bool hasId) { - const bool clicked = ImGui::MenuItem(label, nullptr, false, hasId); +// A menu item that is off, with the reason on hover, while the person has no proved id yet. The +// text is already looked up; `id` is the widget's own, so a translation never moves it. +bool IdMenuItem(const char* text, const char* id, bool hasId) { + const bool clicked = ImGui::MenuItem(l10n::Label(text, id), nullptr, false, hasId); if (!hasId && ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) - ImGui::SetTooltip("Their identity is not proved yet."); + ImGui::SetTooltip("%s", l10n::T("Their identity is not proved yet.")); return clicked; } +// The ban modal's popup id as OpenPopup and IsPopupOpen name it: the "###" form of the id the +// modal's Label carries ("coop"), so a translated title still opens and begins one popup. +constexpr const char* kBanModalId = "###coop"; + } // namespace bool LocalIsHost() { return coop::roster::LocalIsHost(); } // lock-free (overlay hot path) @@ -97,15 +106,21 @@ void Render() { // The header: PLAYERS, the online count in the accent, and a dim key hint for the voice // settings, which live on their own surface. ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.96f, 0.98f, 1.00f, 1.0f)); - ImGui::TextUnformatted("PLAYERS"); + ImGui::TextUnformatted(l10n::T("PLAYERS")); ImGui::PopStyleColor(); ImGui::SameLine(0.0f, S(8.0f)); - if (s.inSession) ImGui::TextColored(ImVec4(0.45f, 0.78f, 1.00f, 1.0f), "%d online", s.count); - else ImGui::TextDisabled("offline"); + if (s.inSession) { + char online[256]; + l10n::Fmt(online, sizeof(online), l10n::T("%d online"), s.count); + ImGui::TextColored(ImVec4(0.45f, 0.78f, 1.00f, 1.0f), "%s", online); + } else { + ImGui::TextDisabled("%s", l10n::T("offline")); + } if (vs.enabled != 0 && vs.started != 0) { - const float bw = ImGui::CalcTextSize("V: voice settings").x; + const char* voiceHint = l10n::T("V: voice settings"); + const float bw = ImGui::CalcTextSize(voiceHint).x; ImGui::SameLine(ImGui::GetContentRegionMax().x - bw); - ImGui::TextDisabled("V: voice settings"); + ImGui::TextDisabled("%s", voiceHint); } ImGui::Spacing(); ImGui::Separator(); @@ -129,20 +144,28 @@ void Render() { // keeps each label off its neighbour's column edge. ImGui::PushStyleVar(ImGuiStyleVar_CellPadding, ImVec2(S(8.0f), S(7.0f))); if (ImGui::BeginTable("##roster", voiceOn ? 5 : 4, tflags)) { - ImGui::TableSetupColumn("Player", ImGuiTableColumnFlags_WidthStretch); + // The column words are looked up once; the setup calls take them with the English + // word as the id, and the hand-drawn header row below draws the bare text. + const char* const wPlayer = l10n::T("Player"); + const char* const wMic = l10n::T("Mic"); + const char* const wLink = l10n::T("Link"); + const char* const wPing = l10n::T("Ping"); + const char* const wId = l10n::T("ID"); + ImGui::TableSetupColumn(l10n::Label(wPlayer, "player"), ImGuiTableColumnFlags_WidthStretch); // The voice-state icon: a click on the self row toggles mute, on a remote row opens the // per-player volume popup. - if (voiceOn) ImGui::TableSetupColumn("Mic", ImGuiTableColumnFlags_WidthFixed, S(40.0f)); + if (voiceOn) + ImGui::TableSetupColumn(l10n::Label(wMic, "mic"), ImGuiTableColumnFlags_WidthFixed, S(40.0f)); // The connection: how this player reaches the session (LAN, direct, relay), or n/a on // the host row, whose traffic never crosses a socket. Host-measured and host-published, // so every board shows the same value for the same player. - ImGui::TableSetupColumn("Link", ImGuiTableColumnFlags_WidthFixed, S(84.0f)); - ImGui::TableSetupColumn("Ping", ImGuiTableColumnFlags_WidthFixed, S(66.0f)); + ImGui::TableSetupColumn(l10n::Label(wLink, "link"), ImGuiTableColumnFlags_WidthFixed, S(84.0f)); + ImGui::TableSetupColumn(l10n::Label(wPing, "ping"), ImGuiTableColumnFlags_WidthFixed, S(66.0f)); // The ID: the occupant's session number, host-issued and never reused within a session. // Not the slot, which names a seat rather than a person, and not shown on the // nameplate; the player list is where the full information lives. Far right, so the // name leads the row. - ImGui::TableSetupColumn("ID", ImGuiTableColumnFlags_WidthFixed, S(44.0f)); + ImGui::TableSetupColumn(l10n::Label(wId, "id"), ImGuiTableColumnFlags_WidthFixed, S(44.0f)); // The headers are drawn by hand rather than with the table's helper, which left-aligns // every label, so the numeric labels sat on the left of their right-aligned values; // each header takes the alignment of the data below it. The headers row flag still @@ -160,15 +183,15 @@ void Render() { ImGui::TextColored(hdrCol, "%s", label); }; ImGui::TableNextRow(ImGuiTableRowFlags_Headers); - headerCell("Player", false); - if (voiceOn) headerCell("Mic", false); - headerCell("Link", false); - headerCell("Ping", true); - headerCell("ID", true); + headerCell(wPlayer, false); + if (voiceOn) headerCell(wMic, false); + headerCell(wLink, false); + headerCell(wPing, true); + headerCell(wId, true); } for (int i = 0; i < s.count; ++i) { const coop::roster::Row& r = s.rows[i]; - const char* nick = r.nick[0] ? r.nick : (r.isLocal ? "Player" : "Remote player"); + const char* nick = r.nick[0] ? r.nick : (r.isLocal ? wPlayer : l10n::T("Remote player")); // A custom nick colour (a synced preference) overrides the role colour; the role // stays readable through the HOST tag. The local row resolves through the local // preference, since our own slot never receives its colour over the wire. @@ -213,12 +236,12 @@ void Render() { // survives the seat changing hands; the command's permission check and // its reply (in the host's chat) are the same as when it is typed. const bool hasId = g_actId[0] != '\0'; - if (IdMenuItem("Teleport to me", hasId)) + if (IdMenuItem(l10n::T("Teleport to me"), "teleport_to_me", hasId)) coop::command_sync::Submit(std::string("tphere ") + g_actId); - if (IdMenuItem("Kick", hasId)) + if (IdMenuItem(l10n::Tc("hud", "Kick"), "kick", hasId)) coop::command_sync::Submit(std::string("kick ") + g_actId); ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.00f, 0.45f, 0.42f, 1.0f)); - const bool banClicked = IdMenuItem("Ban (permanent)", hasId); + const bool banClicked = IdMenuItem(l10n::T("Ban (permanent)"), "ban_permanent", hasId); ImGui::PopStyleColor(); if (banClicked) { // The confirm modal executes after an arbitrary typing delay, exactly @@ -233,14 +256,14 @@ void Render() { } else { ImGui::TextColored(nickCol, "%s", nick); IdTooltip(r, host); - if (r.isLocal) { ImGui::SameLine(0.0f, S(6.0f)); ImGui::TextDisabled("(you)"); } + if (r.isLocal) { ImGui::SameLine(0.0f, S(6.0f)); ImGui::TextDisabled("%s", l10n::T("(you)")); } // HOST belongs on the name, not in the link column: it is a fact about who the // player is, while the link column answers how their traffic reaches the // session, and it explains why the host's link and ping cells read n/a. A host // row is never actionable, so this is its only path. if (r.isHost) { ImGui::SameLine(0.0f, S(6.0f)); - ImGui::TextColored(ImVec4(1.00f, 0.82f, 0.35f, 0.85f), "HOST"); + ImGui::TextColored(ImVec4(1.00f, 0.82f, 0.35f, 0.85f), "%s", l10n::T("HOST")); } } @@ -277,10 +300,10 @@ void Render() { ih * 0.95f, shown, localSilenced ? 0.55f : idle ? 0.30f : 1.0f); if (ImGui::IsItemHovered()) { - const char* lbl = localSilenced ? "Muted for you (click for volume)" - : self ? (vs.muted ? "Your mic is muted -- click to unmute" - : "Click to mute your mic") - : idle ? "Voice connected (click for volume)" + const char* lbl = localSilenced ? l10n::T("Muted for you (click for volume)") + : self ? (vs.muted ? l10n::T("Your mic is muted -- click to unmute") + : l10n::T("Click to mute your mic")) + : idle ? l10n::T("Voice connected (click for volume)") : ui::voice_icons::Label(shown); if (lbl && lbl[0]) ImGui::SetTooltip("%s", lbl); } @@ -289,7 +312,7 @@ void Render() { ImGui::Separator(); float v = vs.slotVolume[r.slot]; bool silenced = v <= 0.001f; - if (ImGui::Checkbox("Mute for me", &silenced)) + if (ImGui::Checkbox(l10n::Label(l10n::T("Mute for me"), "mute_for_me"), &silenced)) coop::voice_chat::SetSlotVolume(r.slot, silenced ? 0.0f : 1.0f); if (!silenced) { if (ImGui::SliderFloat("##pvol", &v, 0.0f, 2.0f, "volume %.2fx")) @@ -337,7 +360,7 @@ void Render() { } ImGui::PopStyleVar(); // CellPadding ImGui::PopStyleColor(2); // TableRowBgAlt + TableBorderLight - if (s.count == 0) ImGui::TextDisabled("No players."); + if (s.count == 0) ImGui::TextDisabled("%s", l10n::T("No players.")); // The host-only session block. A session is configured when it is created and not // afterwards: the password, the lock and the connection are settled in the hosting flow, @@ -349,39 +372,43 @@ void Render() { if (host) { ImGui::Separator(); bool listed = coop::session_manager::ListedState(); - if (ImGui::Checkbox("Show in server browser", &listed)) + if (ImGui::Checkbox(l10n::Label(l10n::T("Show in server browser"), "show_in_server_browser"), + &listed)) coop::session_manager::SetListed(listed); ImGui::SameLine(); - ImGui::TextDisabled(listed ? "(others can find your game)" - : "(hidden -- friends join by invite/IP)"); + ImGui::TextDisabled("%s", listed ? l10n::T("(others can find your game)") + : l10n::T("(hidden -- friends join by invite/IP)")); } // The ban confirmation modal, shared across rows: a ban is destructive and irreversible // from the UI, so it gets an explicit confirm step. Opened once on the transition, closed // by either button. - if (g_banConfirmId[0] != '\0' && !ImGui::IsPopupOpen("Confirm ban##coop")) - ImGui::OpenPopup("Confirm ban##coop"); + if (g_banConfirmId[0] != '\0' && !ImGui::IsPopupOpen(kBanModalId)) + ImGui::OpenPopup(kBanModalId); ImGui::SetNextWindowPos(ImVec2(io.DisplaySize.x * 0.5f, io.DisplaySize.y * 0.5f), ImGuiCond_Always, ImVec2(0.5f, 0.5f)); - if (ImGui::BeginPopupModal("Confirm ban##coop", nullptr, + if (ImGui::BeginPopupModal(l10n::Label(l10n::T("Confirm ban"), "coop"), nullptr, ImGuiWindowFlags_AlwaysAutoResize)) { - ImGui::Text("Permanently ban %s?", g_banConfirmNick); - ImGui::TextDisabled("Disconnected now and refused whenever they rejoin."); - ImGui::Checkbox("Also refuse their address", &g_banConfirmByAddress); + char prompt[256]; + l10n::Fmt(prompt, sizeof(prompt), l10n::T("Permanently ban %s?"), g_banConfirmNick); + ImGui::TextUnformatted(prompt); + ImGui::TextDisabled("%s", l10n::T("Disconnected now and refused whenever they rejoin.")); + ImGui::Checkbox(l10n::Label(l10n::T("Also refuse their address"), "also_refuse_their_address"), + &g_banConfirmByAddress); if (ImGui::IsItemHovered()) - ImGui::SetTooltip("Also refuses anyone connecting from the same address -- people who share " - "their router or their provider's address are refused too. It only works " - "on a direct connection: a player who comes through a relay is refused by " - "their identity alone."); + ImGui::SetTooltip("%s", l10n::T("Also refuses anyone connecting from the same address -- people who share " + "their router or their provider's address are refused too. It only works " + "on a direct connection: a player who comes through a relay is refused by " + "their identity alone.")); ImGui::Spacing(); - if (ImGui::Button("Ban", ImVec2(S(110.f), 0))) { + if (ImGui::Button(l10n::Label(l10n::Tc("hud", "Ban"), "ban"), ImVec2(S(110.f), 0))) { coop::command_sync::Submit(std::string(g_banConfirmByAddress ? "ban " : "banid ") + g_banConfirmId); g_banConfirmId[0] = '\0'; ImGui::CloseCurrentPopup(); } ImGui::SameLine(); - if (ImGui::Button("Cancel", ImVec2(S(110.f), 0))) { + if (ImGui::Button(l10n::Label(l10n::Tc("hud", "Cancel"), "cancel"), ImVec2(S(110.f), 0))) { g_banConfirmId[0] = '\0'; ImGui::CloseCurrentPopup(); } diff --git a/src/votv-coop/src/ui/voice_panel.cpp b/src/votv-coop/src/ui/voice_panel.cpp index 8e0d1cb29..b49932b23 100644 --- a/src/votv-coop/src/ui/voice_panel.cpp +++ b/src/votv-coop/src/ui/voice_panel.cpp @@ -7,6 +7,7 @@ #include "coop/voice/voice_playback.h" #include "coop/config/config.h" #include "coop/player/roster.h" +#include "l10n/l10n.h" #include "ui/scale.h" #include "ui/voice_icons.h" @@ -74,11 +75,11 @@ void RefreshDevices() { // A device combo: "(system default)" + the enumerated names. Sets the row on selection; the row's // subscriber reopens the devices. -void DeviceCombo(const char* label, const coop::config_registry::StringRow& row, char* current, - size_t currentCap, const std::vector& names) { - const char* shown = current[0] ? current : "(system default)"; - if (ImGui::BeginCombo(label, shown)) { - if (ImGui::Selectable("(system default)", !current[0])) { +void DeviceCombo(const char* text, const char* id, const coop::config_registry::StringRow& row, + char* current, size_t currentCap, const std::vector& names) { + const char* shown = current[0] ? current : l10n::T("(system default)"); + if (ImGui::BeginCombo(l10n::Label(text, id), shown)) { + if (ImGui::Selectable(l10n::Label(l10n::T("(system default)"), "system_default"), !current[0])) { current[0] = 0; coop::config::SetValue(row, ""); } @@ -145,23 +146,27 @@ void Render() { ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoSavedSettings)) { if (!s.enabled) { - ImGui::TextWrapped("Voice chat is turned off (voice.enabled=0 in multivoid.ini). " - "Set it to 1 and restart the game to use voice."); + char off[512]; + l10n::Fmt(off, sizeof(off), + l10n::T("Voice chat is turned off (%s=0 in multivoid.ini). " + "Set it to 1 and restart the game to use voice."), + "voice.enabled"); + ImGui::TextWrapped("%s", off); ImGui::End(); return; // closeOnX writes the X back on the way out } if (!s.captureOk) - ImGui::TextColored(ImVec4(1.0f, 0.7f, 0.3f, 1.0f), - "No microphone -- voice is receive-only."); + ImGui::TextColored(ImVec4(1.0f, 0.7f, 0.3f, 1.0f), "%s", + l10n::T("No microphone -- voice is receive-only.")); if (!s.playbackOk) - ImGui::TextColored(ImVec4(1.0f, 0.5f, 0.4f, 1.0f), - "No output device -- you can't hear voice."); + ImGui::TextColored(ImVec4(1.0f, 0.5f, 0.4f, 1.0f), "%s", + l10n::T("No output device -- you can't hear voice.")); // Mic meter row: live level bar + the local state icon. { const float frac = std::clamp((s.micLevelDb + 60.0f) / 60.0f, 0.0f, 1.0f); ImGui::AlignTextToFramePadding(); - ImGui::TextUnformatted("Mic"); + ImGui::TextUnformatted(l10n::T("Mic")); ImGui::SameLine(S(60.0f)); const ImVec4 barCol = s.transmitting ? ImVec4(0.30f, 0.80f, 0.40f, 1.0f) : ImVec4(0.45f, 0.50f, 0.56f, 1.0f); @@ -179,21 +184,27 @@ void Render() { } bool muted = s.muted != 0; - if (ImGui::Checkbox("Mute my microphone", &muted)) VC::SetMuted(muted); + if (ImGui::Checkbox(l10n::Label(l10n::T("Mute my microphone"), "mute_my_microphone"), &muted)) + VC::SetMuted(muted); ImGui::Spacing(); - ImGui::SeparatorText("Activation"); + ImGui::SeparatorText(l10n::Label(l10n::T("Activation"), "activation")); int mode = s.activationMode ? 1 : 0; - char pttLabel[64]; - std::snprintf(pttLabel, sizeof(pttLabel), "Push-to-talk (key: %s)", g_pttKey); - if (ImGui::RadioButton(pttLabel, &mode, 0)) + char pttLabel[256]; + l10n::Fmt(pttLabel, sizeof(pttLabel), l10n::T("Push-to-talk (key: %s)"), g_pttKey); + if (ImGui::RadioButton(l10n::Label(pttLabel, "push_to_talk"), &mode, 0)) coop::config::SetValue(::coop::config_registry::rows::voice_mode, "ptt"); ImGui::SameLine(); ImGui::TextDisabled("(?)"); - if (ImGui::IsItemHovered()) - ImGui::SetTooltip("Hold the key to talk. Change the key via voice.ptt_key\n" - "in multivoid.ini (single letter or a virtual-key number)."); - if (ImGui::RadioButton("Voice activation", &mode, 1)) + if (ImGui::IsItemHovered()) { + char tip[512]; + l10n::Fmt(tip, sizeof(tip), + l10n::T("Hold the key to talk. Change the key via %s\n" + "in multivoid.ini (single letter or a virtual-key number)."), + "voice.ptt_key"); + ImGui::SetTooltip("%s", tip); + } + if (ImGui::RadioButton(l10n::Label(l10n::T("Voice activation"), "voice_activation"), &mode, 1)) coop::config::SetValue(::coop::config_registry::rows::voice_mode, "activation"); // The three sliders: the drag previews live through the voice_chat setters, and the // release commits the row (CommitSlider), whose subscriber is the apply. The pending atomic @@ -212,8 +223,8 @@ void Render() { // applies at once. if (mode == 1) { float thr = s.thresholdDb; - if (ImGui::SliderFloat("Threshold", &thr, -100.0f, 0.0f, "%.0f dB", - ImGuiSliderFlags_AlwaysClamp)) { + if (ImGui::SliderFloat(l10n::Label(l10n::T("Threshold"), "threshold"), &thr, -100.0f, 0.0f, + "%.0f dB", ImGuiSliderFlags_AlwaysClamp)) { VC::SetThresholdDb(thr); g_pendingThreshold.store(thr); } @@ -231,21 +242,21 @@ void Render() { ImGui::SameLine(); ImGui::TextDisabled("(?)"); if (ImGui::IsItemHovered()) - ImGui::SetTooltip("Speech louder than this transmits. Watch the mic\n" - "meter: set the slider just above your room's noise."); + ImGui::SetTooltip("%s", l10n::T("Speech louder than this transmits. Watch the mic\n" + "meter: set the slider just above your room's noise.")); // 0 dB is full scale, so nothing would ever transmit: warn rather than let the panel // sit there silently never activating. if (thr > -5.0f) - ImGui::TextColored(ImVec4(1.0f, 0.7f, 0.3f, 1.0f), - "Threshold near 0 dB -- the mic will almost never " - "trigger. Try -50 dB."); + ImGui::TextColored(ImVec4(1.0f, 0.7f, 0.3f, 1.0f), "%s", + l10n::T("Threshold near 0 dB -- the mic will almost never " + "trigger. Try -50 dB.")); } ImGui::Spacing(); - ImGui::SeparatorText("Levels"); + ImGui::SeparatorText(l10n::Label(l10n::T("Levels"), "levels")); float gain = s.gainDb; - if (ImGui::SliderFloat("Mic gain", &gain, -40.0f, 24.0f, "%+.0f dB", - ImGuiSliderFlags_AlwaysClamp)) { + if (ImGui::SliderFloat(l10n::Label(l10n::T("Mic gain"), "mic_gain"), &gain, -40.0f, 24.0f, + "%+.0f dB", ImGuiSliderFlags_AlwaysClamp)) { VC::SetGainDb(gain); g_pendingGain.store(gain); } @@ -261,8 +272,8 @@ void Render() { g_draggingGain.exchange(false); } float vol = s.masterVolume; - if (ImGui::SliderFloat("Voice volume", &vol, 0.0f, 3.0f, "%.2fx", - ImGuiSliderFlags_AlwaysClamp)) { + if (ImGui::SliderFloat(l10n::Label(l10n::T("Voice volume"), "voice_volume"), &vol, 0.0f, 3.0f, + "%.2fx", ImGuiSliderFlags_AlwaysClamp)) { VC::SetMasterVolume(vol); g_pendingVolume.store(vol); } @@ -282,29 +293,39 @@ void Render() { { coop::roster::Snapshot rs; coop::roster::GetSnapshot(rs); - const char* who = ""; - if (rs.inSession) - who = coop::roster::LocalIsHost() - ? " (your server's; multivoid.ini until the settings page)" - : " (set by the host)"; + // The range as a value, then the sentence that carries it: one msgid per whose + // range it is, so a translation orders the whole line. + char rangeBuf[256]; + const char* range = rangeBuf; if (s.distanceCm > 0.0f) - ImGui::TextDisabled("Voice range: %.0f cm%s", s.distanceCm, who); + l10n::Fmt(rangeBuf, sizeof(rangeBuf), l10n::T("%.0f cm"), s.distanceCm); + else + range = l10n::T("unlimited"); + char line[256]; + if (!rs.inSession) + l10n::Fmt(line, sizeof(line), l10n::T("Voice range: %s"), range); + else if (coop::roster::LocalIsHost()) + l10n::Fmt(line, sizeof(line), + l10n::T("Voice range: %s (your server's; multivoid.ini until the settings page)"), + range); else - ImGui::TextDisabled("Voice range: unlimited%s", who); + l10n::Fmt(line, sizeof(line), l10n::T("Voice range: %s (set by the host)"), range); + ImGui::TextDisabled("%s", line); } ImGui::Spacing(); - ImGui::SeparatorText("Devices"); - DeviceCombo("Microphone", coop::config_registry::rows::voice_mic_device, g_micCurrent, - sizeof(g_micCurrent), g_micDevices); - DeviceCombo("Output", coop::config_registry::rows::voice_output_device, g_outCurrent, - sizeof(g_outCurrent), g_outDevices); - if (ImGui::SmallButton("Rescan devices")) RefreshDevices(); + ImGui::SeparatorText(l10n::Label(l10n::T("Devices"), "devices")); + DeviceCombo(l10n::T("Microphone"), "microphone", coop::config_registry::rows::voice_mic_device, + g_micCurrent, sizeof(g_micCurrent), g_micDevices); + DeviceCombo(l10n::T("Output"), "output", coop::config_registry::rows::voice_output_device, + g_outCurrent, sizeof(g_outCurrent), g_outDevices); + if (ImGui::SmallButton(l10n::Label(l10n::T("Rescan devices"), "rescan_devices"))) + RefreshDevices(); ImGui::SameLine(); ImGui::TextDisabled("(?)"); if (ImGui::IsItemHovered()) - ImGui::SetTooltip("Device changes apply immediately (the voice engine\n" - "reopens on the game's next tick)."); + ImGui::SetTooltip("%s", l10n::T("Device changes apply immediately (the voice engine\n" + "reopens on the game's next tick).")); } ImGui::End(); } From 7173991d7b450ee4e5b5aef07465965c9e272518 Mon Sep 17 00:00:00 2001 From: pelmentor Date: Sat, 10 Oct 2026 06:48:58 +0600 Subject: [PATCH 087/306] [coop] the chat lines each peer composes, in its own language The join, connect, leave, skin-change and turned-away lines are each one msgid now, formatted with the nick as a UTF-8 argument and pushed through new UTF-8 overloads of chat_feed Push and PushDelayed (the wide ones forward to them). The turned-away line has one msgid per verdict kind (game mismatch, unofficial build, official build, malformed join), built from the kind and its values beside the English verdict, which stays the wire reason, the dedup key and the log line. The two fallback names, a remote player and a player, are translated where they reach the feed. Left English: the pile-probe test line. Wire and log strings unchanged. Evidence: on 54b7f628: l10n selftest 75 checks ALL PASS both peers; the l10n drill DONE both peers, translated on en_XA and english Co-Authored-By: Claude Opus 5.5 --- src/votv-coop/include/coop/comms/chat_feed.h | 7 ++ src/votv-coop/src/coop/comms/chat_feed.cpp | 12 ++- .../src/coop/dispatch/event_feed.cpp | 11 ++- .../src/coop/session/player_handshake.cpp | 27 ++++--- .../coop/session/player_handshake_prefs.cpp | 20 +++-- .../coop/session/player_handshake_version.cpp | 74 ++++++++++++++++--- 6 files changed, 114 insertions(+), 37 deletions(-) diff --git a/src/votv-coop/include/coop/comms/chat_feed.h b/src/votv-coop/include/coop/comms/chat_feed.h index 0199a9d79..6df762831 100644 --- a/src/votv-coop/include/coop/comms/chat_feed.h +++ b/src/votv-coop/include/coop/comms/chat_feed.h @@ -100,6 +100,10 @@ struct Snapshot { // interesting for a moment, then clutter. The wide string is UTF-8-encoded on the way in. void Push(const std::wstring& line, Keep keep); +// The same line already in UTF-8: a composed, translated sentence arrives this way, its arguments +// converted by the composer. Identical behaviour to the wide overload, which forwards here. +void Push(const std::string& utf8Line, Keep keep); + // Append a wire-authored chat line the host committed at `lineSeq`, the total order every peer // sorts by. The line starts with the speaker's nick; the byte length and the colour are // resolved by the receiver at apply time. Seeded rows are a joiner's history: they land @@ -121,6 +125,9 @@ void PushAction(const std::string& utf8Line, uint8_t nickBegin, uint8_t nickByte // long it lives. Game thread. void PushDelayed(const std::wstring& line, uint64_t delayMs, Keep keep); +// The UTF-8 form of the delayed push; the wide overload forwards here. +void PushDelayed(const std::string& utf8Line, uint64_t delayMs, Keep keep); + // Drop expired lines, recompute the fade alphas by age, then republish the snapshot; a cheap // no-op when empty. From the periodic game-thread tick. Also advances the suspension // accumulator (see SetChatOpen). diff --git a/src/votv-coop/src/coop/comms/chat_feed.cpp b/src/votv-coop/src/coop/comms/chat_feed.cpp index aee251401..8a6f765fb 100644 --- a/src/votv-coop/src/coop/comms/chat_feed.cpp +++ b/src/votv-coop/src/coop/comms/chat_feed.cpp @@ -404,11 +404,13 @@ void Republish(uint64_t now) { // because the header exports it and the peer-action feed calls it. std::string ToUtf8(const std::wstring& w) { return coop::text::ToUtf8(w); } -void Push(const std::wstring& line, Keep keep) { +void Push(const std::wstring& line, Keep keep) { Push(ToUtf8(line), keep); } + +void Push(const std::string& utf8Line, Keep keep) { const uint64_t now = NowMs(); AdvanceSuspension(now); Entry e; - SetText(e, ToUtf8(line)); + SetText(e, utf8Line); e.key = NextKey(); e.bornMs = now; e.bornSuspendedMs = g_suspendedMs; @@ -462,8 +464,12 @@ void PushAction(const std::string& utf8Line, uint8_t nickBegin, uint8_t nickByte } void PushDelayed(const std::wstring& line, uint64_t delayMs, Keep keep) { + PushDelayed(ToUtf8(line), delayMs, keep); +} + +void PushDelayed(const std::string& utf8Line, uint64_t delayMs, Keep keep) { Pending p; - p.text = ToUtf8(line); + p.text = utf8Line; p.dueMs = NowMs() + delayMs; p.keep = keep; g_pending.push_back(std::move(p)); diff --git a/src/votv-coop/src/coop/dispatch/event_feed.cpp b/src/votv-coop/src/coop/dispatch/event_feed.cpp index c9e9e1ac4..7c3b9975d 100644 --- a/src/votv-coop/src/coop/dispatch/event_feed.cpp +++ b/src/votv-coop/src/coop/dispatch/event_feed.cpp @@ -47,6 +47,7 @@ #include "coop/moderation/seen_players.h" #include "coop/dev/restore_vitals.h" #include "coop/session/teleport_client.h" +#include "l10n/l10n.h" #include "ue_wrap/core/game_thread.h" #include "ue_wrap/core/log.h" @@ -72,10 +73,12 @@ void OnSlotReplaced_LeaveLine(int slot, const coop::roster_ledger::Row& outgoing if (slot == 0) return; // the host slot is not a "peer who left" // The flag suppresses the narration only; the last-seen stamp runs either way. if (!g_suppressLeaveLines) { - coop::chat_feed::Push( - (outgoing.nick.empty() ? std::wstring(L"Remote player") : outgoing.nick) + - L" left the game", - coop::chat_feed::Keep::History); // a departure is part of the lobby's record + const std::string nickUtf8 = outgoing.nick.empty() ? std::string(l10n::T("Remote player")) + : coop::chat_feed::ToUtf8(outgoing.nick); + char line[256]; + if (l10n::Fmt(line, sizeof(line), l10n::T("%1$s left the game"), nickUtf8.c_str()) >= 0) + coop::chat_feed::Push(std::string(line), + coop::chat_feed::Keep::History); // a departure is part of the lobby's record } coop::seen_players::OnSlotDisconnected(slot); // stamp last-seen (host registry) } diff --git a/src/votv-coop/src/coop/session/player_handshake.cpp b/src/votv-coop/src/coop/session/player_handshake.cpp index 43b0f1754..4cf50b6ed 100644 --- a/src/votv-coop/src/coop/session/player_handshake.cpp +++ b/src/votv-coop/src/coop/session/player_handshake.cpp @@ -32,6 +32,7 @@ #include "ue_wrap/core/hot_path_guard.h" #include "coop/comms/chat_bubbles.h" #include "coop/comms/chat_feed.h" +#include "l10n/l10n.h" #include "ue_wrap/core/log.h" #include @@ -424,13 +425,13 @@ bool HandleJoinMessage(net::Session& session, "repeat from a re-sent Join", senderSlot); } else { g_connectAnnounced[senderSlot] = true; - if (session.role() == net::Role::Client) { - coop::chat_feed::Push(L"Connecting to " + nick + L"'s game...", - coop::chat_feed::Keep::Transient); - } else { - coop::chat_feed::Push(nick + L" is connecting to the game...", - coop::chat_feed::Keep::Transient); - } + const std::string nickUtf8 = coop::chat_feed::ToUtf8(nick); + char line[256]; + const int written = + session.role() == net::Role::Client + ? l10n::Fmt(line, sizeof(line), l10n::T("Connecting to %1$s's game..."), nickUtf8.c_str()) + : l10n::Fmt(line, sizeof(line), l10n::T("%1$s is connecting..."), nickUtf8.c_str()); + if (written >= 0) coop::chat_feed::Push(std::string(line), coop::chat_feed::Keep::Transient); UE_LOGI("player_handshake: slot %d connect line shown ('%ls')", senderSlot, nick.c_str()); } // On the host this Join came from a client: the two-way roster broadcast (MTA's initial data @@ -459,8 +460,10 @@ void AnnounceJoinerOnce(int slot) { if (slot < 1 || slot >= net::kMaxPeers) return; // slot 0 = host self; never "joins" if (coop::roster_ledger::Get(slot).joinAnnounced) return; coop::roster_ledger::SetJoinAnnounced(slot, true); - coop::chat_feed::Push(NicknameForSlot(static_cast(slot)) + L" joined the game", - coop::chat_feed::Keep::History); + const std::string nickUtf8 = coop::chat_feed::ToUtf8(NicknameForSlot(static_cast(slot))); + char line[256]; + if (l10n::Fmt(line, sizeof(line), l10n::T("%1$s joined the game"), nickUtf8.c_str()) >= 0) + coop::chat_feed::Push(std::string(line), coop::chat_feed::Keep::History); UE_LOGI("player_handshake: slot %d joined the game (announced at puppet appearance)", slot); } @@ -474,8 +477,10 @@ void AnnouncePeerSpawned(net::Role role, int slot) { if (role == net::Role::Client && slot == 0) { // The self-join line, delayed 5 s: our own loading screen still covers the world at the // spawn moment. Transient. - coop::chat_feed::PushDelayed(L"Joined " + NicknameForSlot(0) + L"'s game", 5000, - coop::chat_feed::Keep::Transient); + const std::string hostUtf8 = coop::chat_feed::ToUtf8(NicknameForSlot(0)); + char line[256]; + if (l10n::Fmt(line, sizeof(line), l10n::T("Joined %1$s's game"), hostUtf8.c_str()) >= 0) + coop::chat_feed::PushDelayed(std::string(line), 5000, coop::chat_feed::Keep::Transient); return; } AnnounceJoinerOnce(slot); diff --git a/src/votv-coop/src/coop/session/player_handshake_prefs.cpp b/src/votv-coop/src/coop/session/player_handshake_prefs.cpp index 3e2f7b193..9546be566 100644 --- a/src/votv-coop/src/coop/session/player_handshake_prefs.cpp +++ b/src/votv-coop/src/coop/session/player_handshake_prefs.cpp @@ -20,6 +20,7 @@ #include "coop/player/nick_color.h" #include "coop/player/players_registry.h" #include "coop/player/skin_registry.h" +#include "l10n/l10n.h" #include "ue_wrap/core/hot_path_guard.h" #include "ue_wrap/core/log.h" @@ -56,6 +57,16 @@ std::vector BuildNickColorChangePayload(uint8_t slot, uint32_t packed) coop::nick_color::B(packed) }; } +// The " changed skin to " line, History: something a PEER did, the same family as a +// peer-action line. The skin is its registry token, shown as the name it is. +void PushSkinChangedLine(uint8_t describedSlot, const std::string& skin) { + const std::string nickUtf8 = coop::chat_feed::ToUtf8(NicknameForSlot(describedSlot)); + char line[256]; + if (l10n::Fmt(line, sizeof(line), l10n::T("%1$s changed skin to %2$s"), nickUtf8.c_str(), + skin.c_str()) >= 0) + coop::chat_feed::Push(std::string(line), coop::chat_feed::Keep::History); +} + } // namespace void AnnounceLocalSkin(net::Session& session, const std::string& name) { @@ -104,10 +115,7 @@ bool HandleSkinChange(net::Session& session, return true; } StoreSkinForSlot(describedSlot, name); - // History: something a PEER did, the same family as a peer-action line. - coop::chat_feed::Push(NicknameForSlot(describedSlot) + L" changed skin to " + - std::wstring(name.begin(), name.end()), - coop::chat_feed::Keep::History); + PushSkinChangedLine(describedSlot, name); // Rebroadcast to every other ready client (originator excluded). const std::vector p = BuildSkinChangePayload(describedSlot, name); for (int x = 1; x < net::kMaxPeers; ++x) { @@ -127,9 +135,7 @@ bool HandleSkinChange(net::Session& session, } if (describedSlot == coop::players::Registry::Get().LocalPeerId()) return true; // our own echo StoreSkinForSlot(describedSlot, name); - coop::chat_feed::Push(NicknameForSlot(describedSlot) + L" changed skin to " + - std::wstring(name.begin(), name.end()), - coop::chat_feed::Keep::History); + PushSkinChangedLine(describedSlot, name); return true; } diff --git a/src/votv-coop/src/coop/session/player_handshake_version.cpp b/src/votv-coop/src/coop/session/player_handshake_version.cpp index 0d501973a..bae7a6901 100644 --- a/src/votv-coop/src/coop/session/player_handshake_version.cpp +++ b/src/votv-coop/src/coop/session/player_handshake_version.cpp @@ -19,6 +19,7 @@ #include "coop/player/roster_ledger.h" #include "coop/session/join_progress.h" #include "coop/version.h" +#include "l10n/l10n.h" #include "ue_wrap/core/log.h" #include @@ -45,7 +46,7 @@ namespace { // peers, with the pose stream then never starting. A field added or removed here must be changed in // both, and the failure is loud but names the wrong field. bool ExtractJoinVersionFields(const uint8_t* payload, size_t len, - std::string* outGame, std::wstring* outNick, + std::string* outGame, std::wstring* outNick, bool* outNickGiven, const uint8_t** outSha, bool* outOfficial) { size_t off = 4; // [u32 senderElementId] (caller already checked len >= 4) // [u8 nicklen][nick] @@ -53,8 +54,10 @@ bool ExtractJoinVersionFields(const uint8_t* payload, size_t len, { const size_t n = payload[off]; if (off + 1 + n > len) return false; - if (n > 0 && outNick) + if (n > 0 && outNick) { *outNick = FromUtf8(payload + off + 1, static_cast(n)); + *outNickGiven = true; + } off += 1 + n; } // [u8 skinlen][skin] -- no guid field precedes it any more; the host derives the guid from the @@ -81,15 +84,27 @@ bool ExtractJoinVersionFields(const uint8_t* payload, size_t len, return true; } +// The kind of a refusal and the values its sentence names, beside the English verdict string: the +// verdict is also the wire reason, the dedup key and the log line, so the host's feed line is +// composed from this in the player's language and never from translating the verdict. +// GameMismatch: arg1 the server's game, arg2 the client's. The build kinds: arg1 the hash prefix. +enum class RefuseKind : uint8_t { Malformed, GameMismatch, UnofficialBuild, OfficialBuild }; +struct RefuseLine { + RefuseKind kind = RefuseKind::Malformed; + std::string arg1; + std::string arg2; +}; + // The wire-gate verdict (server/client-phrased -- the reason string travels in // the GNS close and is read on EITHER end). `peerIsClient` = the validated peer // is a client joining us-the-host; false = we-the-client validate the host's -// Join. Empty = compatible. -std::string WireVersionVerdict(const std::string& peerGame, bool peerIsClient) { +// Join. Empty = compatible; `line` is set only for a verdict. +std::string WireVersionVerdict(const std::string& peerGame, bool peerIsClient, RefuseLine* line) { const char* ourGame = coop::version::kGameTarget; if (peerGame != ourGame) { const std::string& srvGame = peerIsClient ? ourGame : peerGame; const std::string& cliGame = peerIsClient ? peerGame : ourGame; + *line = {RefuseKind::GameMismatch, srvGame, cliGame}; return "Game version mismatch: server plays VOTV " + srvGame + ", client has VOTV " + cliGame + "."; } @@ -115,16 +130,43 @@ uint64_t g_lastRefuseFeedMs = 0; std::string g_lastRefuseFeedKey; constexpr uint64_t kRefuseFeedDedupMs = 30000; -void PushRefuseFeedLineDeduped(const std::wstring& nick, const std::string& reason) { +// The feed sentence for one refusal, one msgid per kind, the end-reason id kept as a %s identifier. +// Returns the bytes written, or -1 when the format is refused. +int FormatRefuseLine(char* buf, size_t size, const std::string& nickUtf8, const RefuseLine& r, + const char* code) { + switch (r.kind) { + case RefuseKind::GameMismatch: + return l10n::Fmt(buf, size, + l10n::T("%1$s was turned away: Game version mismatch: server plays VOTV %2$s, " + "client has VOTV %3$s. [%4$s]"), + nickUtf8.c_str(), r.arg1.c_str(), r.arg2.c_str(), code); + case RefuseKind::UnofficialBuild: + return l10n::Fmt(buf, size, l10n::T("%1$s was turned away: unofficial build %2$s [%3$s]"), + nickUtf8.c_str(), r.arg1.c_str(), code); + case RefuseKind::OfficialBuild: + return l10n::Fmt(buf, size, l10n::T("%1$s was turned away: official build %2$s [%3$s]"), + nickUtf8.c_str(), r.arg1.c_str(), code); + case RefuseKind::Malformed: + break; + } + return l10n::Fmt(buf, size, + l10n::T("%1$s was turned away: malformed join (version field missing) [%2$s]"), + nickUtf8.c_str(), code); +} + +// `nick` and `reason` (the English verdict and its code) are the dedup key; `nickUtf8` and `line` +// are what the player reads. +void PushRefuseFeedLineDeduped(const std::wstring& nick, const std::string& reason, + const std::string& nickUtf8, const RefuseLine& line, const char* code) { const std::string key = std::string(nick.begin(), nick.end()) + "|" + reason; const uint64_t now = ::GetTickCount64(); if (key == g_lastRefuseFeedKey && now - g_lastRefuseFeedMs < kRefuseFeedDedupMs) return; g_lastRefuseFeedKey = key; g_lastRefuseFeedMs = now; - const std::wstring wreason(reason.begin(), reason.end()); // ASCII by construction + char text[256]; + if (FormatRefuseLine(text, sizeof(text), nickUtf8, line, code) < 0) return; // History: a refused join is an EVENT in this lobby, not a passing notice. - coop::chat_feed::Push(nick + L" was turned away: " + wreason, - coop::chat_feed::Keep::History); + coop::chat_feed::Push(std::string(text), coop::chat_feed::Keep::History); } } // namespace @@ -133,16 +175,19 @@ bool ValidateJoinVersionOrRefuse(coop::net::Session& session, int senderSlot, const uint8_t* payload, size_t payloadLen) { std::string peerGame; std::wstring refuseNick = L"A player"; + bool nickGiven = false; const uint8_t* peerSha = nullptr; bool peerOfficial = false; std::string verdict; + RefuseLine feedLine; net::EndReason code = net::EndReason::GameVersionRefused; - if (!ExtractJoinVersionFields(payload, payloadLen, &peerGame, &refuseNick, &peerSha, - &peerOfficial)) { + if (!ExtractJoinVersionFields(payload, payloadLen, &peerGame, &refuseNick, &nickGiven, + &peerSha, &peerOfficial)) { verdict = "malformed join (version field missing)"; + feedLine.kind = RefuseKind::Malformed; } else { verdict = WireVersionVerdict(peerGame, - session.role() == net::Role::Host); + session.role() == net::Role::Host, &feedLine); } // The host decides, both ways. MTA's server admits a join only when the client's netcode version // equals its own (reference/mtasa-blue/Server/mods/deathmatch/logic/CGame.cpp:1913). A non-public @@ -169,9 +214,11 @@ bool ValidateJoinVersionOrRefuse(coop::net::Session& session, int senderSlot, admittedOther = true; } else if (hostOfficial) { verdict = "unofficial build " + ShaPrefixHex(peerSha); + feedLine = {RefuseKind::UnofficialBuild, ShaPrefixHex(peerSha), {}}; code = net::EndReason::UnofficialClientRefused; } else { verdict = "official build " + ShaPrefixHex(peerSha); + feedLine = {RefuseKind::OfficialBuild, ShaPrefixHex(peerSha), {}}; code = net::EndReason::OfficialClientRefused; } } @@ -189,7 +236,10 @@ bool ValidateJoinVersionOrRefuse(coop::net::Session& session, int senderSlot, if (session.role() == net::Role::Host) { UE_LOGW("player_handshake: Join REFUSED (slot=%d nick='%ls' game='%s'): %s", senderSlot, refuseNick.c_str(), peerGame.c_str(), verdict.c_str()); - PushRefuseFeedLineDeduped(refuseNick, verdict + " [" + net::Describe(code).id + "]"); + const std::string nickUtf8 = + nickGiven ? coop::chat_feed::ToUtf8(refuseNick) : std::string(l10n::T("A player")); + PushRefuseFeedLineDeduped(refuseNick, verdict + " [" + net::Describe(code).id + "]", nickUtf8, + feedLine, net::Describe(code).id); char reason[128]; std::snprintf(reason, sizeof(reason), "%s", verdict.c_str()); // The close carries the code and the sentence: their popup names both. From 6d709d6bd7a7152e953770610df9dcccf7cf3954 Mon Sep 17 00:00:00 2001 From: pelmentor Date: Sat, 10 Oct 2026 06:49:42 +0600 Subject: [PATCH 088/306] [coop] the connecting line keeps its English; the feed's fallback name The host's line for a joiner still loading read "X is connecting to the game..." and the lookup's msgid had dropped its last words; it carries them again, so the English is unchanged. The peer-action feed's fallback name for an actor with none is looked up like the others. Co-Authored-By: Claude Opus 5.5 --- src/votv-coop/src/coop/comms/peer_action_feed.cpp | 2 +- src/votv-coop/src/coop/session/player_handshake.cpp | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/votv-coop/src/coop/comms/peer_action_feed.cpp b/src/votv-coop/src/coop/comms/peer_action_feed.cpp index 8439db1b3..27dcd4e95 100644 --- a/src/votv-coop/src/coop/comms/peer_action_feed.cpp +++ b/src/votv-coop/src/coop/comms/peer_action_feed.cpp @@ -98,7 +98,7 @@ std::string NickOf(uint8_t slot) { const std::wstring& nickW = isLocal ? coop::player_handshake::LocalNickname() : coop::player_handshake::NicknameForSlot(static_cast(slot)); - return coop::chat_feed::ToUtf8(nickW.empty() ? std::wstring(L"Player") : nickW); + return nickW.empty() ? std::string(l10n::T("Player")) : coop::chat_feed::ToUtf8(nickW); } // The sentence in the player's language, formatted with the nick as argument 1, and the nick's span diff --git a/src/votv-coop/src/coop/session/player_handshake.cpp b/src/votv-coop/src/coop/session/player_handshake.cpp index 4cf50b6ed..4e74af8f4 100644 --- a/src/votv-coop/src/coop/session/player_handshake.cpp +++ b/src/votv-coop/src/coop/session/player_handshake.cpp @@ -430,7 +430,7 @@ bool HandleJoinMessage(net::Session& session, const int written = session.role() == net::Role::Client ? l10n::Fmt(line, sizeof(line), l10n::T("Connecting to %1$s's game..."), nickUtf8.c_str()) - : l10n::Fmt(line, sizeof(line), l10n::T("%1$s is connecting..."), nickUtf8.c_str()); + : l10n::Fmt(line, sizeof(line), l10n::T("%1$s is connecting to the game..."), nickUtf8.c_str()); if (written >= 0) coop::chat_feed::Push(std::string(line), coop::chat_feed::Keep::Transient); UE_LOGI("player_handshake: slot %d connect line shown ('%ls')", senderSlot, nick.c_str()); } From 1668819e8d6063b0ce32e7c7dbf8ef7ad55fad37 Mon Sep 17 00:00:00 2001 From: pelmentor Date: Sat, 10 Oct 2026 07:01:33 +0600 Subject: [PATCH 089/306] [coop] settings, sleep and confirmations in the player's language The setting announcement is one msgid with the row's label and the value as arguments: a flag shows on or off under the setting_value context, an enum or number stays its token. The two labelled config rows and their descriptions are marked, and the Server settings pane draws them through the lookup, with its buttons and notes. The sleep lines, the local skin, nameplate and nickname-colour confirmations and the three command lines the typing peer shows are looked up too; logs keep their English. Evidence: on 54b7f628: l10n selftest 75 checks ALL PASS both peers; the l10n drill DONE both peers, translated on en_XA and english Co-Authored-By: Claude Opus 5.5 --- .../coop/commands/command_dispatcher.h | 6 ++++-- .../include/coop/config/config_registry.h | 2 ++ .../coop/config/config_registry_rows.inc | 12 +++++------ .../src/coop/commands/command_sync.cpp | 18 ++++++++++++----- src/votv-coop/src/coop/player/local_body.cpp | 15 ++++++++------ src/votv-coop/src/coop/player/nameplate.cpp | 5 +++-- src/votv-coop/src/coop/player/nick_color.cpp | 8 +++++--- src/votv-coop/src/coop/player/sleep_sync.cpp | 18 +++++++++-------- .../src/coop/session/server_settings_sync.cpp | 20 ++++++++++++------- src/votv-coop/src/ui/server_settings_pane.cpp | 19 +++++++++++------- 10 files changed, 77 insertions(+), 46 deletions(-) diff --git a/src/votv-coop/include/coop/commands/command_dispatcher.h b/src/votv-coop/include/coop/commands/command_dispatcher.h index e6c44bd54..15eb9ef64 100644 --- a/src/votv-coop/include/coop/commands/command_dispatcher.h +++ b/src/votv-coop/include/coop/commands/command_dispatcher.h @@ -14,6 +14,7 @@ #include "coop/commands/command_registry.h" #include "coop/commands/command_targets.h" #include "coop/permissions/context_set.h" +#include "l10n/mark.h" #include #include @@ -89,8 +90,9 @@ struct DispatchResult { }; // What an empty line (a bare `/`) is answered with: by Dispatch, and by a client, which never -// dispatches and shows it itself. -inline constexpr std::string_view kHelpHint = "Type /help for the commands."; +// dispatches and shows it itself. The literal is marked for translation and NUL-terminated, so a +// client looks it up with l10n::T(kHelpHint.data()). +inline constexpr std::string_view kHelpHint = L10N_MARK("Type /help for the commands."); // `line` is the text after the `/`. Game thread on the host (its callers); pure here. DispatchResult Dispatch(const Registry& reg, const Caller& caller, std::string_view line, diff --git a/src/votv-coop/include/coop/config/config_registry.h b/src/votv-coop/include/coop/config/config_registry.h index 6e6e4506e..b9ac4dc28 100644 --- a/src/votv-coop/include/coop/config/config_registry.h +++ b/src/votv-coop/include/coop/config/config_registry.h @@ -14,6 +14,8 @@ #pragma once +#include "l10n/mark.h" + #include #include #include diff --git a/src/votv-coop/include/coop/config/config_registry_rows.inc b/src/votv-coop/include/coop/config/config_registry_rows.inc index 685976b6f..8c1a79e10 100644 --- a/src/votv-coop/include/coop/config/config_registry_rows.inc +++ b/src/votv-coop/include/coop/config/config_registry_rows.inc @@ -224,17 +224,17 @@ CFG_STRING(voice_output_device, "voice.output_device", "voice", "", nullptr, fal CFG_FLOAT(voice_volume, "voice.volume", "voice", 1.0f, 0, 10, nullptr, "Master playback volume multiplier for incoming voice.") CFG_FLOAT(voice_distance_cm, "voice.distance_cm", "voice", 4800.0f, 0, 1000000, nullptr, - "Maximum voice audibility distance in centimeters (4800 = 48 m); 0 = unlimited (a whisper " + L10N_MARK("Maximum voice audibility distance in centimeters (4800 = 48 m); 0 = unlimited (a whisper " "stays local); the host's setting in a session. A value of 1..99 is a near-mute range, the " - "host's to choose.") + "host's to choose.")) CFG_ROWFLAGS(voice_distance_cm, kRowServer | kRowReplicated | kRowLive | kRowNotify) -CFG_LABEL(voice_distance_cm, "Voice range") +CFG_LABEL(voice_distance_cm, L10N_MARK("Voice range")) CFG_FLAG(net_allow_other_builds, "net.allow_other_builds", "net", false, "VOTVCOOP_NET_ALLOW_OTHER_BUILDS", - "Let players whose Multivoid build differs from this server's join -- an unofficial build when this server runs " + L10N_MARK("Let players whose Multivoid build differs from this server's join -- an unofficial build when this server runs " "the official one, the official build when this server runs a modified one. Applies to the next joins; players " - "already in stay.") + "already in stay.")) CFG_ROWFLAGS(net_allow_other_builds, kRowServer | kRowReplicated | kRowLive | kRowNotify) -CFG_LABEL(net_allow_other_builds, "Allow other builds") +CFG_LABEL(net_allow_other_builds, L10N_MARK("Allow other builds")) CFG_INT(voice_jitter_threshold, "voice.jitter_threshold", "voice", 3, 0, 60, nullptr, "Jitter buffer: late-packet tolerance in frames before a reset.") CFG_INT(voice_prebuffer_frames, "voice.prebuffer_frames", "voice", 5, 0, 60, nullptr, diff --git a/src/votv-coop/src/coop/commands/command_sync.cpp b/src/votv-coop/src/coop/commands/command_sync.cpp index df18409e6..d57914dd5 100644 --- a/src/votv-coop/src/coop/commands/command_sync.cpp +++ b/src/votv-coop/src/coop/commands/command_sync.cpp @@ -33,6 +33,7 @@ #include "coop/player/roster_ledger.h" #include "coop/session/player_handshake.h" #include "coop/text/utf8_codec.h" +#include "l10n/l10n.h" #include "ue_wrap/core/game_thread.h" #include "ue_wrap/core/hot_path_guard.h" @@ -236,6 +237,13 @@ void Deliver(std::string_view utf8, const std::wstring& wide) { if (g_observer) g_observer(utf8); } +// The chat form of a line this peer composes for itself, in its language; the log and the +// observer keep the English view. `msgid` views a NUL-terminated literal, so .data() is a C string. +std::wstring Shown(std::string_view msgid) { + const char* text = l10n::T(msgid.data()); + return coop::text::FromUtf8Lossy(text, std::strlen(text)); +} + void ReadPosition(PlayerView& v, void* actor) { ue_wrap::FVector p{}; if (!actor || !ue_wrap::engine::TryGetActorLocation(actor, p)) return; @@ -356,8 +364,8 @@ void Submit(std::string line) { // A line the request cannot carry whole is refused on every peer, before the role decides // where it runs: it never runs cut, and a host and a client answer alike. if (line.size() > kLineMax) { - const std::string_view tooLong = "That line is too long."; - Deliver(tooLong, coop::text::FromUtf8Lossy(tooLong.data(), tooLong.size())); + const std::string_view tooLong = L10N_MARK("That line is too long."); + Deliver(tooLong, Shown(tooLong)); return; } if (!inSession || s->role() == coop::net::Role::Host) { @@ -368,15 +376,15 @@ void Submit(std::string line) { // has nothing to send; the hint is local text. if (line.empty()) { const std::string_view hint = coop::commands::kHelpHint; - Deliver(hint, coop::text::FromUtf8Lossy(hint.data(), hint.size())); + Deliver(hint, Shown(hint)); return; } coop::net::CommandRequestPayload p{}; p.len = static_cast(line.size()); std::memcpy(p.text, line.data(), line.size()); if (!s->SendReliable(coop::net::ReliableKind::CommandRequest, &p, sizeof(p))) { - const std::string_view failed = "Could not send the command."; - Deliver(failed, coop::text::FromUtf8Lossy(failed.data(), failed.size())); + const std::string_view failed = L10N_MARK("Could not send the command."); + Deliver(failed, Shown(failed)); } }); } diff --git a/src/votv-coop/src/coop/player/local_body.cpp b/src/votv-coop/src/coop/player/local_body.cpp index d32c40edc..24a5ede9c 100644 --- a/src/votv-coop/src/coop/player/local_body.cpp +++ b/src/votv-coop/src/coop/player/local_body.cpp @@ -8,6 +8,7 @@ #include "coop/player/skin_registry.h" #include "coop/session/player_handshake.h" #include "coop/config/config.h" +#include "l10n/l10n.h" #include "ue_wrap/core/game_thread.h" #include "ue_wrap/core/hot_path_guard.h" #include "ue_wrap/core/log.h" @@ -100,10 +101,11 @@ void RequestSkin(const std::string& name) { if (coop::client_model::CanWearSkin(name) == coop::client_model::Wearable::No) { UE_LOGW("local_body: skin '%s' cannot be worn on this machine -- keeping '%s'; " "not persisted and not announced", name.c_str(), g_skin.c_str()); - coop::chat_feed::Push( - L"Skin '" + std::wstring(name.begin(), name.end()) + - L"' is not installed here -- keeping your current one", - coop::chat_feed::Keep::Transient); + char line[256]; + if (l10n::Fmt(line, sizeof(line), + l10n::T("Skin '%s' is not installed here -- keeping your current one"), + name.c_str()) >= 0) + coop::chat_feed::Push(std::string(line), coop::chat_feed::Keep::Transient); return; } // Persist only what resolved. (File IO from the game thread is what the ordering @@ -116,8 +118,9 @@ void RequestSkin(const std::string& name) { coop::player_handshake::AnnounceLocalSkin(*s, name); // Transient: this player's own UI confirmation. The line other peers see about // this change (" changed skin to X", player_handshake_prefs) IS History. - coop::chat_feed::Push(L"Skin: " + std::wstring(name.begin(), name.end()), - coop::chat_feed::Keep::Transient); + char line[256]; + if (l10n::Fmt(line, sizeof(line), l10n::T("Skin: %s"), name.c_str()) >= 0) + coop::chat_feed::Push(std::string(line), coop::chat_feed::Keep::Transient); }); } diff --git a/src/votv-coop/src/coop/player/nameplate.cpp b/src/votv-coop/src/coop/player/nameplate.cpp index 378064970..19380c142 100644 --- a/src/votv-coop/src/coop/player/nameplate.cpp +++ b/src/votv-coop/src/coop/player/nameplate.cpp @@ -9,6 +9,7 @@ #include "coop/session/player_handshake.h" #include "coop/voice/voice_chat.h" #include "coop/config/config.h" +#include "l10n/l10n.h" #include "ue_wrap/engine/engine.h" #include "ue_wrap/core/log.h" #include "ue_wrap/core/trace.h" // LineBlockedStatDyn -- the occlusion (gray plate) check @@ -200,8 +201,8 @@ void OnNameplateRowChanged() { if (coop::net::Session* s = g_session.load(std::memory_order_acquire)) coop::player_handshake::AnnounceLocalNameplate(*s, visible); // Transient: this player's own UI confirmation, not the lobby's record. - coop::chat_feed::Push(visible ? L"Nameplate: shown to other players" - : L"Nameplate: hidden from other players", + coop::chat_feed::Push(std::string(visible ? l10n::T("Nameplate: shown to other players") + : l10n::T("Nameplate: hidden from other players")), coop::chat_feed::Keep::Transient); } diff --git a/src/votv-coop/src/coop/player/nick_color.cpp b/src/votv-coop/src/coop/player/nick_color.cpp index a936b17b3..9fe087bc2 100644 --- a/src/votv-coop/src/coop/player/nick_color.cpp +++ b/src/votv-coop/src/coop/player/nick_color.cpp @@ -4,6 +4,7 @@ #include "coop/player/players_registry.h" #include "coop/session/player_handshake.h" #include "coop/config/config.h" +#include "l10n/l10n.h" #include "ue_wrap/core/log.h" #include @@ -68,9 +69,10 @@ void OnNickColorRowChanged() { if (coop::net::Session* s = g_session.load(std::memory_order_acquire)) coop::player_handshake::AnnounceLocalNickColor(*s, packed); // Transient: this player's own UI confirmation, not the lobby's record. - coop::chat_feed::Push(IsCustom(packed) ? L"Nickname color: applied (synced to other players)" - : L"Nickname color: reset to default", - coop::chat_feed::Keep::Transient); + coop::chat_feed::Push( + std::string(IsCustom(packed) ? l10n::T("Nickname color: applied (synced to other players)") + : l10n::T("Nickname color: reset to default")), + coop::chat_feed::Keep::Transient); } } // namespace diff --git a/src/votv-coop/src/coop/player/sleep_sync.cpp b/src/votv-coop/src/coop/player/sleep_sync.cpp index 765abe234..b10d3ccf8 100644 --- a/src/votv-coop/src/coop/player/sleep_sync.cpp +++ b/src/votv-coop/src/coop/player/sleep_sync.cpp @@ -4,13 +4,14 @@ #include "coop/comms/chat_feed.h" #include "coop/net/session.h" +#include "l10n/l10n.h" #include "ue_wrap/core/log.h" #include "ue_wrap/actors/sleep.h" #include "ue_wrap/world/world_singleton.h" #include -#include +#include namespace coop::sleep_sync { namespace { @@ -34,13 +35,14 @@ bool IsHost(coop::net::Session* s) { return s->role() == coop::net::Role::Host; void PushCounterLine(uint8_t count, uint8_t total) { if (count == 0) return; - wchar_t buf[64]; - ::swprintf(buf, 64, L"%u/%u players sleeping", static_cast(count), - static_cast(total)); + char buf[128]; + if (l10n::Fmt(buf, sizeof(buf), l10n::T("%1$u/%2$u players sleeping"), + static_cast(count), static_cast(total)) < 0) + return; // All three sleep lines are Transient: they are a live STATUS readout of an // in-progress ritual ("2/3 players sleeping"), meaningless once it is over -- // reading them back hours later would be noise, not history. - coop::chat_feed::Push(buf, coop::chat_feed::Keep::Transient); + coop::chat_feed::Push(std::string(buf), coop::chat_feed::Keep::Transient); } void ApplyDreamProbPolicy(coop::net::Session* s) { @@ -71,7 +73,7 @@ void ApplyAccelerateLocal(coop::net::Session* s, bool on) { if (SLP::SetSleepViewTarget(SLP::SleepCam())) UE_LOGI("sleep_sync: camera -> sleepCam (the timelapse view)"); } - coop::chat_feed::Push(L"Everyone is asleep -- the night passes...", + coop::chat_feed::Push(std::string(l10n::T("Everyone is asleep -- the night passes...")), coop::chat_feed::Keep::Transient); UE_LOGI("sleep_sync: ACCELERATE (local dilation -> %s)", SLP::IsSleeping() ? "20" : "1 (not in bed)"); @@ -87,8 +89,8 @@ void ApplyEndLocal(coop::net::Session* s, bool natural) { ApplyAccelerateLocal(s, false); if (SLP::IsSleeping()) SLP::CallWakeup(); if (natural) SLP::WriteSleepNeed(100.f); - coop::chat_feed::Push(natural ? L"Good morning -- everyone is rested." - : L"Sleep interrupted -- everyone wakes up.", + coop::chat_feed::Push(std::string(natural ? l10n::T("Good morning -- everyone is rested.") + : l10n::T("Sleep interrupted -- everyone wakes up.")), coop::chat_feed::Keep::Transient); UE_LOGI("sleep_sync: END (natural=%d)", natural ? 1 : 0); } diff --git a/src/votv-coop/src/coop/session/server_settings_sync.cpp b/src/votv-coop/src/coop/session/server_settings_sync.cpp index ae9bdaef5..68b717d7c 100644 --- a/src/votv-coop/src/coop/session/server_settings_sync.cpp +++ b/src/votv-coop/src/coop/session/server_settings_sync.cpp @@ -11,7 +11,7 @@ #include "coop/net/protocol.h" #include "coop/net/session.h" #include "coop/player/roster_ledger.h" -#include "coop/text/utf8_codec.h" +#include "l10n/l10n.h" #include "ue_wrap/core/hot_path_guard.h" #include "ue_wrap/core/log.h" @@ -69,11 +69,17 @@ std::string ShownText(const reg::Row* row) { // The chat line for a changed notify row: our own label and our own rendering of the value, // never the sender's text. Source prints the event's cvar name and value string // (clientmode_shared.cpp:1235-1251); we print our label and our own rendering of the held value. -std::wstring AnnouncementLine(const reg::Row* row) { - const char* label = reg::RowLabel(row); +// The label and a flag's on/off are looked up in this peer's language; an enum or number stays +// its token, the word the player also types in /set. The log lines keep ShownText's English. +void PushAnnouncementLine(const reg::Row* row) { const std::string shown = ShownText(row); - return L"Server setting changed: " + coop::text::FromUtf8Lossy(label, std::strlen(label)) + - L" is now " + coop::text::FromUtf8Lossy(shown.data(), shown.size()) + L"."; + const char* value = shown.c_str(); + if (row->kind == reg::Kind::Flag) + value = shown == "on" ? l10n::Tc("setting_value", "on") : l10n::Tc("setting_value", "off"); + char line[256]; + if (l10n::Fmt(line, sizeof(line), l10n::T("Server setting changed: %1$s is now %2$s."), + l10n::T(reg::RowLabel(row)), value) >= 0) + coop::chat_feed::Push(std::string(line), coop::chat_feed::Keep::History); } // One row to one slot. The value is printed through the registry's one printed form. @@ -122,7 +128,7 @@ void OnReplicatedRowChanged() { Session* s = g_session; if (!s || !s->running() || s->role() != Role::Host) return; for (const reg::Row* row : announced) { - coop::chat_feed::Push(AnnouncementLine(row), coop::chat_feed::Keep::History); + PushAnnouncementLine(row); UE_LOGI("server_settings: host announced %s=%s", row->key, ShownText(row).c_str()); } size_t count = 0; @@ -228,7 +234,7 @@ void HandleServerSetting(Session& session, const Session::ReliableMessage& msg) // any other row reaches no line. if (accepted && (p.flags & net::kServerSettingAnnounced) && reg::IsNotify(row)) { const uint32_t n = g_announcedCount.fetch_add(1) + 1; - coop::chat_feed::Push(AnnouncementLine(row), coop::chat_feed::Keep::History); + PushAnnouncementLine(row); UE_LOGI("server_settings: announced #%u %s=%s", n, row->key, ShownText(row).c_str()); } } diff --git a/src/votv-coop/src/ui/server_settings_pane.cpp b/src/votv-coop/src/ui/server_settings_pane.cpp index 1a4c17d2c..e322786ed 100644 --- a/src/votv-coop/src/ui/server_settings_pane.cpp +++ b/src/votv-coop/src/ui/server_settings_pane.cpp @@ -17,6 +17,7 @@ #include "coop/commands/command_sync.h" #include "coop/config/config.h" #include "coop/config/config_registry.h" +#include "l10n/l10n.h" #include "ui/scale.h" #include "imgui.h" @@ -95,9 +96,12 @@ void DrawTyped(const CR::Row& row, RowState& st, const std::string& now) { ImGui::InputText("##value", st.edit, sizeof(st.edit)); st.focused = ImGui::IsItemActive(); ImGui::SameLine(); - if (ImGui::SmallButton("Apply")) SubmitLine(std::string("set ") + row.key + " " + st.edit); + if (ImGui::SmallButton(l10n::Label(l10n::Tc("f1", "Apply"), "apply"))) + SubmitLine(std::string("set ") + row.key + " " + st.edit); ImGui::SameLine(); - ImGui::Text("now: %s", now.c_str()); + char nowLine[kEditMax + 64]; + l10n::Fmt(nowLine, sizeof(nowLine), l10n::T("now: %s"), now.c_str()); + ImGui::TextUnformatted(nowLine); } // An enum: a combo of the row's tokens, the one equal to the cooked current value selected. @@ -130,13 +134,13 @@ void DrawRow(const CR::Row& row, const char* label) { Sync(st, now); ImGui::AlignTextToFramePadding(); - ImGui::TextUnformatted(label); + ImGui::TextUnformatted(l10n::T(label)); ImGui::SameLine(); ImGui::TextDisabled("(?)"); if (ImGui::IsItemHovered()) { ImGui::BeginTooltip(); ImGui::PushTextWrapPos(S(420.0f)); - ImGui::TextUnformatted(row.desc); + ImGui::TextUnformatted(l10n::T(row.desc)); ImGui::PopTextWrapPos(); ImGui::EndTooltip(); } @@ -151,8 +155,9 @@ void DrawRow(const CR::Row& row, const char* label) { case CR::Kind::Identity: break; } ImGui::SameLine(); - if (ImGui::SmallButton("Reset")) SubmitLine(std::string("reset ") + row.key); - if (!CR::IsLive(&row)) ImGui::TextDisabled("Takes effect at the next session."); + if (ImGui::SmallButton(l10n::Label(l10n::Tc("f1", "Reset"), "reset"))) + SubmitLine(std::string("reset ") + row.key); + if (!CR::IsLive(&row)) ImGui::TextDisabled("%s", l10n::T("Takes effect at the next session.")); ImGui::Spacing(); ImGui::PopID(); } @@ -171,7 +176,7 @@ void Render() { DrawRow(*row, label); ++drawn; } - if (drawn == 0) ImGui::TextDisabled("This server has no settings to change here."); + if (drawn == 0) ImGui::TextDisabled("%s", l10n::T("This server has no settings to change here.")); } } // namespace ui::server_settings_pane From b1b1c39eacec73a4efe86f4bfc716a5d215461d7 Mon Sep 17 00:00:00 2001 From: pelmentor Date: Sat, 10 Oct 2026 07:10:49 +0600 Subject: [PATCH 090/306] [ui] the remaining drawn text in the player's language The link words, the overlay font roles, the bug report's validation sentence and left-out reasons, the end-of-session sentences, the voice icon tooltips, the two window titles and the volume slider format are looked up where they are drawn. Tables and returned literals are marked at their definition, so the same strings keep their English where they also go to a log, the wire or the report bundle. The Console and Voice chat titles keep their ### ids through Label. Evidence: on 54b7f628: l10n selftest 75 checks ALL PASS both peers; the l10n drill DONE both peers, translated on en_XA and english Co-Authored-By: Claude Opus 5.5 --- .../src/coop/bug_report/report_files.cpp | 7 +- .../src/coop/bug_report/report_form.cpp | 5 +- src/votv-coop/src/coop/net/end_reason.cpp | 133 +++++++++--------- src/votv-coop/src/ui/bug_report_pane.cpp | 5 +- src/votv-coop/src/ui/console.cpp | 2 +- src/votv-coop/src/ui/end_reason_dialog.cpp | 2 +- src/votv-coop/src/ui/fonts.cpp | 13 +- src/votv-coop/src/ui/link_format.cpp | 18 +-- src/votv-coop/src/ui/scoreboard.cpp | 2 +- src/votv-coop/src/ui/voice_icons.cpp | 12 +- src/votv-coop/src/ui/voice_panel.cpp | 2 +- 11 files changed, 106 insertions(+), 95 deletions(-) diff --git a/src/votv-coop/src/coop/bug_report/report_files.cpp b/src/votv-coop/src/coop/bug_report/report_files.cpp index 6d7bfbb2b..91a312a1a 100644 --- a/src/votv-coop/src/coop/bug_report/report_files.cpp +++ b/src/votv-coop/src/coop/bug_report/report_files.cpp @@ -4,6 +4,7 @@ #include "coop/bug_report/report_bundle.h" #include "report_stream.h" +#include "l10n/mark.h" #include "ue_wrap/core/log.h" #include "ue_wrap/core/paths.h" @@ -25,7 +26,7 @@ Entry MakeEntry(const char* name, std::wstring path, bool tailOnly) { WIN32_FILE_ATTRIBUTE_DATA d{}; if (e.path.empty() || !::GetFileAttributesExW(e.path.c_str(), GetFileExInfoStandard, &d) || (d.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) != 0) { - e.leftOut = "not present"; + e.leftOut = L10N_MARK("not present"); return e; } e.bytesOnDisk = (static_cast(d.nFileSizeHigh) << 32) | d.nFileSizeLow; @@ -136,8 +137,8 @@ std::vector ListEntries() { if (prev.leftOut.empty()) { switch (CheckLogFormat(prev.path)) { case LogFormat::Same: break; - case LogFormat::Other: prev.leftOut = "written by a build with another log format"; break; - case LogFormat::Unreadable: prev.leftOut = "could not be read"; break; + case LogFormat::Other: prev.leftOut = L10N_MARK("written by a build with another log format"); break; + case LogFormat::Unreadable: prev.leftOut = L10N_MARK("could not be read"); break; } } out.push_back(std::move(prev)); diff --git a/src/votv-coop/src/coop/bug_report/report_form.cpp b/src/votv-coop/src/coop/bug_report/report_form.cpp index ba90d8936..fda00a342 100644 --- a/src/votv-coop/src/coop/bug_report/report_form.cpp +++ b/src/votv-coop/src/coop/bug_report/report_form.cpp @@ -2,6 +2,7 @@ #include "coop/bug_report/report_core.h" +#include "l10n/mark.h" #include "ue_wrap/core/log.h" namespace coop::bug_report { @@ -27,10 +28,10 @@ std::string_view Trimmed(std::string_view s) { const char* ValidateForm(const Form& f) { if (CodePoints(Trimmed(f.happened)) < kHappenedMinChars) - return "Please describe what happened (at least 20 characters)."; + return L10N_MARK("Please describe what happened (at least 20 characters)."); if (f.happened.size() > kFieldMaxBytes || f.expected.size() > kFieldMaxBytes || f.contact.size() > kContactMaxBytes) - return "That text is too long."; + return L10N_MARK("That text is too long."); return nullptr; } diff --git a/src/votv-coop/src/coop/net/end_reason.cpp b/src/votv-coop/src/coop/net/end_reason.cpp index c5e79f71a..671cc5a8b 100644 --- a/src/votv-coop/src/coop/net/end_reason.cpp +++ b/src/votv-coop/src/coop/net/end_reason.cpp @@ -3,6 +3,7 @@ #include "coop/net/end_reason.h" +#include "l10n/mark.h" #include "ue_wrap/core/log.h" #include @@ -19,76 +20,78 @@ struct Row { }; // One row per enumerator, the id a literal. A code's meaning lives here and nowhere else. +// The sentence is marked for the template: the end-of-session dialog looks it up where it draws it, +// and every log line, the transport close text and the host status keep this English literal. constexpr Row kRows[] = { // J -- the joiner decided - {EndReason::MasterUnreachable, {"MV-J01", "Could not reach the server list."}}, - {EndReason::BadAddress, {"MV-J02", "The server list gave a bad address for this host."}}, - {EndReason::JoinError, {"MV-J03", "The join failed before it started. The log has the error."}}, - {EndReason::CouldNotStart, {"MV-J04", "Could not start the connection."}}, + {EndReason::MasterUnreachable, {"MV-J01", L10N_MARK("Could not reach the server list.")}}, + {EndReason::BadAddress, {"MV-J02", L10N_MARK("The server list gave a bad address for this host.")}}, + {EndReason::JoinError, {"MV-J03", L10N_MARK("The join failed before it started. The log has the error.")}}, + {EndReason::CouldNotStart, {"MV-J04", L10N_MARK("Could not start the connection.")}}, {EndReason::RetiredJoinTimedOut, {"MV-J05", "(retired)"}}, - {EndReason::ShuttingDown, {"MV-J06", "The game is shutting down."}}, - {EndReason::GameVersionMismatch, {"MV-J07", "The host plays a different version of the game."}}, - {EndReason::HostNewer, {"MV-J08", "The host runs a newer build of the mod. Update to join."}}, - {EndReason::HostOlder, {"MV-J09", "The host runs an older build of the mod. They need to update."}}, - {EndReason::IdentityExchange, {"MV-J10", "Could not start the identity exchange with this host."}}, - {EndReason::BadChallenge, {"MV-J11", "The host sent an identity challenge this build cannot read."}}, - {EndReason::HostNotProved, {"MV-J12", "The host could not prove its identity."}}, - {EndReason::PasswordUnbound, {"MV-J13", "This server needs a password, and nothing told this machine which host it was dialling."}}, - {EndReason::PasswordMissing, {"MV-J14", "This server needs a password."}}, - {EndReason::PasswordProof, {"MV-J15", "Could not compute the password proof on this machine."}}, - {EndReason::CouldNotSendProof, {"MV-J16", "Could not send the identity proof to the host."}}, - {EndReason::ClientBacklogFatal, {"MV-J17", "The connection fell too far behind and was closed."}}, - {EndReason::LeftSession, {"MV-J18", "Left the session."}}, - {EndReason::RendezvousUnreachable, {"MV-J19", "This machine has no working connection to the signaling server, so the host could not be dialled."}}, - {EndReason::NoRendezvousAnswer, {"MV-J20", "The host did not answer through the signaling server. It may be offline, or reconnecting -- try again in a minute."}}, - {EndReason::HostWorldNotPrepared, {"MV-J21", "The host never finished preparing its world for you."}}, - {EndReason::WorldDownloadStalled, {"MV-J22", "The download of the host's world stopped part way through."}}, - {EndReason::HostWorldNotSent, {"MV-J23", "The host never sent you the world it owed."}}, - {EndReason::WorldWouldNotLoad, {"MV-J24", "The host's world would not load on this machine."}}, - {EndReason::WorldUnusable, {"MV-J25", "The host's world arrived damaged and could not be used."}}, - {EndReason::ProfileNotSent, {"MV-J26", "The host never sent your inventory."}}, - {EndReason::WorldNeverSettled, {"MV-J27", "Your game loaded the world but never finished settling into it."}}, - {EndReason::IcePolicyUnreadable, {"MV-J28", "Your net.ice setting could not be read as all or relay. Set it to one of them in multivoid.ini, or make the file readable."}}, - {EndReason::RelayWithoutServer, {"MV-J29", "Your net.ice=relay allows only a relayed connection, and this session has no relay server."}}, - {EndReason::RelayRefusesDirect, {"MV-J30", "Your net.ice=relay keeps your address from other players, and a direct connection would show it to the host. Join a lobby that is not direct, or remove net.ice from multivoid.ini."}}, + {EndReason::ShuttingDown, {"MV-J06", L10N_MARK("The game is shutting down.")}}, + {EndReason::GameVersionMismatch, {"MV-J07", L10N_MARK("The host plays a different version of the game.")}}, + {EndReason::HostNewer, {"MV-J08", L10N_MARK("The host runs a newer build of the mod. Update to join.")}}, + {EndReason::HostOlder, {"MV-J09", L10N_MARK("The host runs an older build of the mod. They need to update.")}}, + {EndReason::IdentityExchange, {"MV-J10", L10N_MARK("Could not start the identity exchange with this host.")}}, + {EndReason::BadChallenge, {"MV-J11", L10N_MARK("The host sent an identity challenge this build cannot read.")}}, + {EndReason::HostNotProved, {"MV-J12", L10N_MARK("The host could not prove its identity.")}}, + {EndReason::PasswordUnbound, {"MV-J13", L10N_MARK("This server needs a password, and nothing told this machine which host it was dialling.")}}, + {EndReason::PasswordMissing, {"MV-J14", L10N_MARK("This server needs a password.")}}, + {EndReason::PasswordProof, {"MV-J15", L10N_MARK("Could not compute the password proof on this machine.")}}, + {EndReason::CouldNotSendProof, {"MV-J16", L10N_MARK("Could not send the identity proof to the host.")}}, + {EndReason::ClientBacklogFatal, {"MV-J17", L10N_MARK("The connection fell too far behind and was closed.")}}, + {EndReason::LeftSession, {"MV-J18", L10N_MARK("Left the session.")}}, + {EndReason::RendezvousUnreachable, {"MV-J19", L10N_MARK("This machine has no working connection to the signaling server, so the host could not be dialled.")}}, + {EndReason::NoRendezvousAnswer, {"MV-J20", L10N_MARK("The host did not answer through the signaling server. It may be offline, or reconnecting -- try again in a minute.")}}, + {EndReason::HostWorldNotPrepared, {"MV-J21", L10N_MARK("The host never finished preparing its world for you.")}}, + {EndReason::WorldDownloadStalled, {"MV-J22", L10N_MARK("The download of the host's world stopped part way through.")}}, + {EndReason::HostWorldNotSent, {"MV-J23", L10N_MARK("The host never sent you the world it owed.")}}, + {EndReason::WorldWouldNotLoad, {"MV-J24", L10N_MARK("The host's world would not load on this machine.")}}, + {EndReason::WorldUnusable, {"MV-J25", L10N_MARK("The host's world arrived damaged and could not be used.")}}, + {EndReason::ProfileNotSent, {"MV-J26", L10N_MARK("The host never sent your inventory.")}}, + {EndReason::WorldNeverSettled, {"MV-J27", L10N_MARK("Your game loaded the world but never finished settling into it.")}}, + {EndReason::IcePolicyUnreadable, {"MV-J28", L10N_MARK("Your net.ice setting could not be read as all or relay. Set it to one of them in multivoid.ini, or make the file readable.")}}, + {EndReason::RelayWithoutServer, {"MV-J29", L10N_MARK("Your net.ice=relay allows only a relayed connection, and this session has no relay server.")}}, + {EndReason::RelayRefusesDirect, {"MV-J30", L10N_MARK("Your net.ice=relay keeps your address from other players, and a direct connection would show it to the host. Join a lobby that is not direct, or remove net.ice from multivoid.ini.")}}, // H -- the host decided - {EndReason::WrongPassword, {"MV-H01", "Wrong password."}}, - {EndReason::PasswordRequired, {"MV-H02", "This server needs a password."}}, - {EndReason::TooManyPasswordAttempts, {"MV-H03", "Too many password attempts. Try again in ten minutes."}}, - {EndReason::HostCannotCheckPassword, {"MV-H04", "The host could not check the password."}}, - {EndReason::IdentityNotProved, {"MV-H05", "Your identity proof did not verify."}}, - {EndReason::NoKeyIdentity, {"MV-H06", "Your connection presented no key identity."}}, - {EndReason::IdentityChanged, {"MV-H07", "Your identity changed during the exchange."}}, - {EndReason::MalformedHello, {"MV-H08", "The host could not read your hello."}}, - {EndReason::DuplicateHello, {"MV-H09", "A second hello arrived on one connection."}}, - {EndReason::ProofBeforeHello, {"MV-H10", "A proof arrived before a hello."}}, - {EndReason::MalformedProof, {"MV-H11", "The host could not read your proof."}}, - {EndReason::SpokeBeforeProving, {"MV-H12", "Something was sent before the identity was proved."}}, - {EndReason::MalformedPacket, {"MV-H13", "The host received a malformed packet."}}, - {EndReason::HostNoRandomness, {"MV-H14", "The host has no randomness to challenge with."}}, - {EndReason::CouldNotSendChallenge, {"MV-H15", "The host could not send its challenge."}}, - {EndReason::IdentityProofTimedOut, {"MV-H16", "The identity proof did not arrive in time."}}, - {EndReason::TooSlowToProve, {"MV-H17", "The host was busy, and this connection was the slowest to prove itself."}}, - {EndReason::Banned, {"MV-H18", "You are banned from this server."}}, - {EndReason::BannedByHost, {"MV-H19", "The host banned you."}}, - {EndReason::KickedByHost, {"MV-H20", "The host kicked you."}}, - {EndReason::HostFull, {"MV-H21", "The server is full."}}, - {EndReason::GameVersionRefused, {"MV-H22", "The host plays a different version of the game."}}, - {EndReason::BuildMismatch, {"MV-H23", "Your mod build differs from the host's."}}, - {EndReason::Superseded, {"MV-H24", "A newer connection with your identity replaced this one."}}, - {EndReason::HostStopped, {"MV-H25", "The host ended the session."}}, - {EndReason::HostBacklogFatal, {"MV-H26", "The host closed the connection: it fell too far behind."}}, - {EndReason::AcceptFailed, {"MV-H27", "The host could not accept the connection."}}, - {EndReason::HostClosed, {"MV-H28", "The host closed the connection."}}, - {EndReason::ConnectFlood, {"MV-H29", "Too many connections from you in a short time. Try again shortly."}}, - {EndReason::UnofficialClientRefused, {"MV-H30", "This server admits only the official Multivoid build. Install the official release (the whole zip)."}}, - {EndReason::OfficialClientRefused, {"MV-H31", "This server runs a modified (unofficial) Multivoid build and admits only modified builds."}}, + {EndReason::WrongPassword, {"MV-H01", L10N_MARK("Wrong password.")}}, + {EndReason::PasswordRequired, {"MV-H02", L10N_MARK("This server needs a password.")}}, + {EndReason::TooManyPasswordAttempts, {"MV-H03", L10N_MARK("Too many password attempts. Try again in ten minutes.")}}, + {EndReason::HostCannotCheckPassword, {"MV-H04", L10N_MARK("The host could not check the password.")}}, + {EndReason::IdentityNotProved, {"MV-H05", L10N_MARK("Your identity proof did not verify.")}}, + {EndReason::NoKeyIdentity, {"MV-H06", L10N_MARK("Your connection presented no key identity.")}}, + {EndReason::IdentityChanged, {"MV-H07", L10N_MARK("Your identity changed during the exchange.")}}, + {EndReason::MalformedHello, {"MV-H08", L10N_MARK("The host could not read your hello.")}}, + {EndReason::DuplicateHello, {"MV-H09", L10N_MARK("A second hello arrived on one connection.")}}, + {EndReason::ProofBeforeHello, {"MV-H10", L10N_MARK("A proof arrived before a hello.")}}, + {EndReason::MalformedProof, {"MV-H11", L10N_MARK("The host could not read your proof.")}}, + {EndReason::SpokeBeforeProving, {"MV-H12", L10N_MARK("Something was sent before the identity was proved.")}}, + {EndReason::MalformedPacket, {"MV-H13", L10N_MARK("The host received a malformed packet.")}}, + {EndReason::HostNoRandomness, {"MV-H14", L10N_MARK("The host has no randomness to challenge with.")}}, + {EndReason::CouldNotSendChallenge, {"MV-H15", L10N_MARK("The host could not send its challenge.")}}, + {EndReason::IdentityProofTimedOut, {"MV-H16", L10N_MARK("The identity proof did not arrive in time.")}}, + {EndReason::TooSlowToProve, {"MV-H17", L10N_MARK("The host was busy, and this connection was the slowest to prove itself.")}}, + {EndReason::Banned, {"MV-H18", L10N_MARK("You are banned from this server.")}}, + {EndReason::BannedByHost, {"MV-H19", L10N_MARK("The host banned you.")}}, + {EndReason::KickedByHost, {"MV-H20", L10N_MARK("The host kicked you.")}}, + {EndReason::HostFull, {"MV-H21", L10N_MARK("The server is full.")}}, + {EndReason::GameVersionRefused, {"MV-H22", L10N_MARK("The host plays a different version of the game.")}}, + {EndReason::BuildMismatch, {"MV-H23", L10N_MARK("Your mod build differs from the host's.")}}, + {EndReason::Superseded, {"MV-H24", L10N_MARK("A newer connection with your identity replaced this one.")}}, + {EndReason::HostStopped, {"MV-H25", L10N_MARK("The host ended the session.")}}, + {EndReason::HostBacklogFatal, {"MV-H26", L10N_MARK("The host closed the connection: it fell too far behind.")}}, + {EndReason::AcceptFailed, {"MV-H27", L10N_MARK("The host could not accept the connection.")}}, + {EndReason::HostClosed, {"MV-H28", L10N_MARK("The host closed the connection.")}}, + {EndReason::ConnectFlood, {"MV-H29", L10N_MARK("Too many connections from you in a short time. Try again shortly.")}}, + {EndReason::UnofficialClientRefused, {"MV-H30", L10N_MARK("This server admits only the official Multivoid build. Install the official release (the whole zip).")}}, + {EndReason::OfficialClientRefused, {"MV-H31", L10N_MARK("This server runs a modified (unofficial) Multivoid build and admits only modified builds.")}}, // T -- the transport decided - {EndReason::Timeout, {"MV-T01", "No answer from the host."}}, - {EndReason::NoRoute, {"MV-T02", "No route to the host through its firewall or router."}}, - {EndReason::Rendezvous, {"MV-T03", "The signaling server could not reach the host."}}, - {EndReason::TransportHandshake, {"MV-T04", "The transport handshake with the host failed."}}, - {EndReason::LinkLost, {"MV-T05", "The connection was lost."}}, + {EndReason::Timeout, {"MV-T01", L10N_MARK("No answer from the host.")}}, + {EndReason::NoRoute, {"MV-T02", L10N_MARK("No route to the host through its firewall or router.")}}, + {EndReason::Rendezvous, {"MV-T03", L10N_MARK("The signaling server could not reach the host.")}}, + {EndReason::TransportHandshake, {"MV-T04", L10N_MARK("The transport handshake with the host failed.")}}, + {EndReason::LinkLost, {"MV-T05", L10N_MARK("The connection was lost.")}}, }; constexpr int kRowCount = static_cast(sizeof(kRows) / sizeof(kRows[0])); diff --git a/src/votv-coop/src/ui/bug_report_pane.cpp b/src/votv-coop/src/ui/bug_report_pane.cpp index 0233b4bce..58093a7c6 100644 --- a/src/votv-coop/src/ui/bug_report_pane.cpp +++ b/src/votv-coop/src/ui/bug_report_pane.cpp @@ -60,7 +60,8 @@ void DrawFiles() { char line[256]; for (const BR::Entry& e : g_entries) { if (!e.leftOut.empty()) { - l10n::Fmt(line, sizeof(line), l10n::T("%1$s -- left out: %2$s"), e.name, e.leftOut.c_str()); + l10n::Fmt(line, sizeof(line), l10n::T("%1$s -- left out: %2$s"), e.name, + l10n::T(e.leftOut.c_str())); ImGui::TextDisabled("%s", line); } else if (e.tailOnly && e.bytesOnDisk > BR::kUe4ssTailBytes) { l10n::Fmt(line, sizeof(line), l10n::T("%1$s (last %2$llu KB of %3$llu KB)"), e.name, @@ -143,7 +144,7 @@ void Render() { ImGui::EndDisabled(); if (g_formError) { ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.00f, 0.45f, 0.42f, 1.0f)); - ImGui::TextWrapped("%s", g_formError); + ImGui::TextWrapped("%s", l10n::T(g_formError)); ImGui::PopStyleColor(); } DrawStatus(st); diff --git a/src/votv-coop/src/ui/console.cpp b/src/votv-coop/src/ui/console.cpp index 05c058e4e..ead93cc5e 100644 --- a/src/votv-coop/src/ui/console.cpp +++ b/src/votv-coop/src/ui/console.cpp @@ -109,7 +109,7 @@ void Render() { ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, S(6.0f)); const ImGuiWindowFlags flags = ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_NoCollapse; - if (ImGui::Begin("Console###coop_console", nullptr, flags)) { + if (ImGui::Begin(l10n::Label(l10n::T("Console"), "coop_console"), nullptr, flags)) { // Log scroll region (reserve a line for the input box). const float inputH = ImGui::GetFrameHeightWithSpacing(); if (ImGui::BeginChild("##log", ImVec2(0.0f, -inputH), false, diff --git a/src/votv-coop/src/ui/end_reason_dialog.cpp b/src/votv-coop/src/ui/end_reason_dialog.cpp index c082ab9f3..4ebe99971 100644 --- a/src/votv-coop/src/ui/end_reason_dialog.cpp +++ b/src/votv-coop/src/ui/end_reason_dialog.cpp @@ -62,7 +62,7 @@ void Render() { ImGui::PushTextWrapPos(S(404.0f)); // The code's own sentence first; the site's text under it when it adds something (the // two builds, the host's own words), never the same line twice. - ImGui::TextWrapped("%s", info.text); + ImGui::TextWrapped("%s", l10n::T(info.text)); if (!notice.detail.empty() && notice.detail != info.text) { ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.72f, 0.66f, 0.66f, 1.0f)); ImGui::TextWrapped("%s", notice.detail.c_str()); diff --git a/src/votv-coop/src/ui/fonts.cpp b/src/votv-coop/src/ui/fonts.cpp index 6c9afbe02..646811cfe 100644 --- a/src/votv-coop/src/ui/fonts.cpp +++ b/src/votv-coop/src/ui/fonts.cpp @@ -8,6 +8,7 @@ #include "coop/config/config.h" #include "coop/config/config_registry.h" #include "coop/text/repertoire.h" +#include "l10n/l10n.h" #include "ui/scale.h" #include "ue_wrap/core/log.h" @@ -69,11 +70,11 @@ struct RoleDesc { bool bold; // use the family's Bold face }; constexpr RoleDesc kRoles[kRoleCount] = { - { "Menu / panels", kUiPx, false }, // Role::Menu (== ImGui default) - { "Chat", kChatPx, true }, // Role::Chat - { "Net stats", kUiPx, false }, // Role::Net - { "Nameplates", kNameplatePx, false }, // Role::Nameplate - { "Release toast", kUiPx, false }, // Role::Toast (our update/version toast) + { L10N_MARK("Menu / panels"), kUiPx, false }, // Role::Menu (== ImGui default) + { L10N_MARK("Chat"), kChatPx, true }, // Role::Chat + { L10N_MARK("Net stats"), kUiPx, false }, // Role::Net + { L10N_MARK("Nameplates"), kNameplatePx, false }, // Role::Nameplate + { L10N_MARK("Release toast"), kUiPx, false }, // Role::Toast (our update/version toast) }; // The per-role default family, owned by the registry's row list: the menu, chat and toast // default to Fixedsys, the net stats and nameplates to Roboto. @@ -366,7 +367,7 @@ const char* FamilyLabel(Family f) { const char* RoleLabel(Role r) { const int i = static_cast(r); - return (i >= 0 && i < kRoleCount) ? kRoles[i].label : "?"; + return (i >= 0 && i < kRoleCount) ? l10n::T(kRoles[i].label) : "?"; } Family RoleFamily(Role r) { diff --git a/src/votv-coop/src/ui/link_format.cpp b/src/votv-coop/src/ui/link_format.cpp index f70c08c20..a6b37afc8 100644 --- a/src/votv-coop/src/ui/link_format.cpp +++ b/src/votv-coop/src/ui/link_format.cpp @@ -2,6 +2,8 @@ #include "ui/link_format.h" +#include "l10n/l10n.h" + #include namespace ui::link_format { @@ -10,10 +12,10 @@ using coop::net::LinkKind; const char* LinkLabel(LinkKind kind) { switch (kind) { - case LinkKind::Local: return "n/a"; // the host -- no link exists to describe - case LinkKind::Lan: return "LAN"; - case LinkKind::Direct: return "DIRECT"; - case LinkKind::Relayed: return "RELAY"; + case LinkKind::Local: return l10n::T("n/a"); // the host -- no link exists to describe + case LinkKind::Lan: return l10n::T("LAN"); + case LinkKind::Direct: return l10n::T("DIRECT"); + case LinkKind::Relayed: return l10n::T("RELAY"); case LinkKind::Unknown: break; } return "--"; // no measurement has landed yet @@ -21,16 +23,16 @@ const char* LinkLabel(LinkKind kind) { void FormatPing(int pingMs, LinkKind kind, char* out, int outLen) { if (!out || outLen <= 0) return; - if (kind == LinkKind::Local) { std::snprintf(out, static_cast(outLen), "n/a"); return; } + if (kind == LinkKind::Local) { l10n::Fmt(out, static_cast(outLen), "%s", l10n::T("n/a")); return; } if (pingMs > 0) std::snprintf(out, static_cast(outLen), "%dms", pingMs); else if (pingMs == 0) std::snprintf(out, static_cast(outLen), "<1ms"); // sub-ms LAN else std::snprintf(out, static_cast(outLen), "--"); } const char* LobbyLinkLabel(const std::string& word) { - if (word == "relay") return "Relay"; - if (word == "direct") return "Direct"; - if (word == "lan") return "LAN"; + if (word == "relay") return l10n::T("Relay"); + if (word == "direct") return l10n::T("Direct"); + if (word == "lan") return l10n::T("LAN"); return "--"; } diff --git a/src/votv-coop/src/ui/scoreboard.cpp b/src/votv-coop/src/ui/scoreboard.cpp index 110cf5d88..c5a24b8d5 100644 --- a/src/votv-coop/src/ui/scoreboard.cpp +++ b/src/votv-coop/src/ui/scoreboard.cpp @@ -315,7 +315,7 @@ void Render() { if (ImGui::Checkbox(l10n::Label(l10n::T("Mute for me"), "mute_for_me"), &silenced)) coop::voice_chat::SetSlotVolume(r.slot, silenced ? 0.0f : 1.0f); if (!silenced) { - if (ImGui::SliderFloat("##pvol", &v, 0.0f, 2.0f, "volume %.2fx")) + if (ImGui::SliderFloat("##pvol", &v, 0.0f, 2.0f, l10n::T("volume %.2fx"))) coop::voice_chat::SetSlotVolume(r.slot, v); } ImGui::EndPopup(); diff --git a/src/votv-coop/src/ui/voice_icons.cpp b/src/votv-coop/src/ui/voice_icons.cpp index 677a64653..b215b0d55 100644 --- a/src/votv-coop/src/ui/voice_icons.cpp +++ b/src/votv-coop/src/ui/voice_icons.cpp @@ -2,6 +2,8 @@ #include "ui/voice_icons.h" +#include "l10n/l10n.h" + #include namespace ui::voice_icons { @@ -97,11 +99,11 @@ void Draw(ImDrawList* dl, ImVec2 c, float size, VoiceIcon icon, float alpha) { const char* Label(VoiceIcon icon) { switch (icon) { - case VoiceIcon::Talking: return "Talking"; - case VoiceIcon::Whispering: return "Whispering"; - case VoiceIcon::MicMuted: return "Mic muted"; - case VoiceIcon::Disabled: return "Voice off"; - case VoiceIcon::Disconnected: return "Voice disconnected"; + case VoiceIcon::Talking: return l10n::T("Talking"); + case VoiceIcon::Whispering: return l10n::T("Whispering"); + case VoiceIcon::MicMuted: return l10n::T("Mic muted"); + case VoiceIcon::Disabled: return l10n::T("Voice off"); + case VoiceIcon::Disconnected: return l10n::T("Voice disconnected"); default: return ""; } } diff --git a/src/votv-coop/src/ui/voice_panel.cpp b/src/votv-coop/src/ui/voice_panel.cpp index b49932b23..1c26cd3e4 100644 --- a/src/votv-coop/src/ui/voice_panel.cpp +++ b/src/votv-coop/src/ui/voice_panel.cpp @@ -142,7 +142,7 @@ void Render() { if (!open) g_open.store(false, std::memory_order_relaxed); } } closeOnX{open}; - if (ImGui::Begin("Voice chat###coop_voice_panel", &open, + if (ImGui::Begin(l10n::Label(l10n::T("Voice chat"), "coop_voice_panel"), &open, ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoSavedSettings)) { if (!s.enabled) { From 301adbb255ddd1c993f6e96236edfd56cfecfefe Mon Sep 17 00:00:00 2001 From: pelmentor Date: Sat, 10 Oct 2026 07:13:03 +0600 Subject: [PATCH 091/306] [ui] the report's status, the browser's update line, the font combo's id The bug report's status sentences are marked where the report is made, English in its log, and drawn translated by the pane. The browser's update line is composed by the pane from the superseding build's name, so it can be translated while the stored line stays English for the main menu's label and the log. The font-role combo keeps a fixed id under its translated label, and an unused colour leaves voice_icons. Co-Authored-By: Claude Opus 5.5 --- .../include/coop/session/session_manager.h | 4 ++++ .../src/coop/bug_report/report_bundle.cpp | 18 +++++++++++------- .../src/coop/session/session_manager.cpp | 12 ++++++++++-- src/votv-coop/src/ui/bug_report_pane.cpp | 2 +- src/votv-coop/src/ui/dev_menu.cpp | 2 +- src/votv-coop/src/ui/server_browser_panels.cpp | 14 +++++++++++--- src/votv-coop/src/ui/voice_icons.cpp | 1 - 7 files changed, 38 insertions(+), 15 deletions(-) diff --git a/src/votv-coop/include/coop/session/session_manager.h b/src/votv-coop/include/coop/session/session_manager.h index a0d0a96c4..d278b91ea 100644 --- a/src/votv-coop/include/coop/session/session_manager.h +++ b/src/votv-coop/include/coop/session/session_manager.h @@ -146,6 +146,10 @@ bool ConnectP2PDirect(const std::string& hostIdentity, const coop::net::Config& // until a check completes; an unreachable master keeps the last known line. void RefreshLatestVersion(); std::string LatestVersionLine(bool* outdated); +// The build that supersedes this one ("b230", or the release's name) when the last check found one, +// else empty: for a surface that composes its own sentence in the player's language. The line above +// stays English for the main menu's label and the log. +std::string LatestSuperseding(); // The host hide toggle, a visibility request; the session stays live. Mirrored for ListedState. void SetListed(bool listed); diff --git a/src/votv-coop/src/coop/bug_report/report_bundle.cpp b/src/votv-coop/src/coop/bug_report/report_bundle.cpp index a41923e32..b1bc80fde 100644 --- a/src/votv-coop/src/coop/bug_report/report_bundle.cpp +++ b/src/votv-coop/src/coop/bug_report/report_bundle.cpp @@ -7,6 +7,8 @@ // final Publish (Done or Failed) is its release. #include "coop/bug_report/report_bundle.h" + +#include "l10n/mark.h" #include "report_stream.h" #include "coop/atomic_file/atomic_file.h" @@ -331,9 +333,11 @@ std::wstring MoveToFreeName(const std::wstring& part, const std::wstring& base, } Status Build(const Form& form, const Capture& cap) { - constexpr const char* kWriteFailed = "Could not write the report file."; - constexpr const char* kReadLog = "Could not read the log."; - constexpr const char* kClosing = "The game is closing."; + // The status sentences are marked for translation and drawn translated by the pane (l10n::T on + // Status::error); the log keeps them English. + constexpr const char* kWriteFailed = L10N_MARK("Could not write the report file."); + constexpr const char* kReadLog = L10N_MARK("Could not read the log."); + constexpr const char* kClosing = L10N_MARK("The game is closing."); const std::wstring exeDir = ue_wrap::paths::ExeDir(); if (exeDir.empty()) return Fail(kWriteFailed, "the game folder's path is empty"); const std::wstring reports = exeDir + L"\\multivoid_reports"; @@ -352,7 +356,7 @@ Status Build(const Form& form, const Capture& cap) { const bool iniOk = iniRead == coop::config::IniReport::Ok; std::vector entries = ListEntries(); if (!entries[0].leftOut.empty()) - return Fail("Could not find the log.", "multivoid.log is " + entries[0].leftOut); + return Fail(L10N_MARK("Could not find the log."), "multivoid.log is " + entries[0].leftOut); Redactor redactor(std::move(ctx)); const std::string reportText = ReportText(form); @@ -447,7 +451,7 @@ Status Build(const Form& form, const Capture& cap) { } void FailToStart(const std::string& cause) { - Publish(Fail("Could not start the report.", cause)); + Publish(Fail(L10N_MARK("Could not start the report."), cause)); } // The worker thread's whole body. An exception escaping a detached thread is std::terminate, so @@ -463,9 +467,9 @@ void WorkerMain(const Form& form, const Capture& cap) { try { result = Build(form, cap); } catch (const std::exception& e) { - result = Fail("The report could not be made.", std::string("exception: ") + e.what()); + result = Fail(L10N_MARK("The report could not be made."), std::string("exception: ") + e.what()); } catch (...) { - result = Fail("The report could not be made.", "a non-standard exception"); + result = Fail(L10N_MARK("The report could not be made."), "a non-standard exception"); } Publish(std::move(result)); } catch (...) { diff --git a/src/votv-coop/src/coop/session/session_manager.cpp b/src/votv-coop/src/coop/session/session_manager.cpp index c1b9f74b9..5d1c2ac75 100644 --- a/src/votv-coop/src/coop/session/session_manager.cpp +++ b/src/votv-coop/src/coop/session/session_manager.cpp @@ -209,6 +209,7 @@ namespace { std::mutex g_latestMu; std::string g_latestLine; // empty until a check completes WITH a verdict bool g_latestOutdated = false; // amber tint when true +std::string g_latestSuperseding; // the superseding build's name when outdated, else empty std::atomic g_latestInFlight{false}; std::atomic g_latestFetchMs{0}; // GetTickCount64() of the last fetch START (0 = none yet) constexpr uint64_t kLatestMinIntervalMs = 8000; // no-DoS floor between /v1/latest fetches @@ -231,6 +232,7 @@ void RefreshLatestVersion() { if (info.ok && info.proto > 0) { // an unreachable master keeps the last known line const int ours = static_cast(net::kProtocolVersion); std::string line; + std::string superseding; bool outdated = false; if (info.proto == ours) { // The current build is the latest release: the compact "(latest)" tag. @@ -240,8 +242,8 @@ void RefreshLatestVersion() { // No URL: the label is a one-line UTextBlock, not a hyperlink, so an address // would cost ~37 characters to deliver a string nobody can act on. What stays // is which build supersedes yours. - line = DisplayVersion() + " -- UPDATE AVAILABLE: " + - (info.mod.empty() ? ("b" + std::to_string(info.proto)) : info.mod); + superseding = info.mod.empty() ? ("b" + std::to_string(info.proto)) : info.mod; + line = DisplayVersion() + " -- UPDATE AVAILABLE: " + superseding; } else { // We are newer than the master's latest (a dev build); informational. line = DisplayVersion() + " (dev; latest released b" + @@ -251,6 +253,7 @@ void RefreshLatestVersion() { std::lock_guard lk(g_latestMu); g_latestLine = line; g_latestOutdated = outdated; + g_latestSuperseding = superseding; } } catch (const std::exception& e) { UE_LOGW("session_manager: version check worker exception: %s", e.what()); @@ -265,6 +268,11 @@ std::string LatestVersionLine(bool* outdated) { return g_latestLine; } +std::string LatestSuperseding() { + std::lock_guard lk(g_latestMu); + return g_latestSuperseding; +} + void Refresh() { Client().RefreshAsync(slots::Selected().url, /*versionFilter=*/std::string()); // show all } diff --git a/src/votv-coop/src/ui/bug_report_pane.cpp b/src/votv-coop/src/ui/bug_report_pane.cpp index 58093a7c6..93acbdffc 100644 --- a/src/votv-coop/src/ui/bug_report_pane.cpp +++ b/src/votv-coop/src/ui/bug_report_pane.cpp @@ -114,7 +114,7 @@ void DrawStatus(const BR::Status& st) { } case BR::Phase::Failed: ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.00f, 0.45f, 0.42f, 1.0f)); - ImGui::TextWrapped("%s", st.error.c_str()); + ImGui::TextWrapped("%s", l10n::T(st.error.c_str())); // marked where report_bundle defines it ImGui::PopStyleColor(); break; } diff --git a/src/votv-coop/src/ui/dev_menu.cpp b/src/votv-coop/src/ui/dev_menu.cpp index 2da46be28..1e9cdf36d 100644 --- a/src/votv-coop/src/ui/dev_menu.cpp +++ b/src/votv-coop/src/ui/dev_menu.cpp @@ -152,7 +152,7 @@ void RenderFontPref() { int cur = static_cast(F::RoleFamily(role)); ImGui::PushID(r); ImGui::SetNextItemWidth(ImGui::GetFontSize() * 10.f); - if (ImGui::Combo(F::RoleLabel(role), &cur, famItems, F::kFamilyCount)) + if (ImGui::Combo(l10n::Label(F::RoleLabel(role), "font_role"), &cur, famItems, F::kFamilyCount)) coop::config::SetValue(coop::config_registry::FontRoleRow(static_cast(r)), F::FamilyToken(static_cast(cur))); ImGui::PopID(); diff --git a/src/votv-coop/src/ui/server_browser_panels.cpp b/src/votv-coop/src/ui/server_browser_panels.cpp index f69b4f946..418bc72f0 100644 --- a/src/votv-coop/src/ui/server_browser_panels.cpp +++ b/src/votv-coop/src/ui/server_browser_panels.cpp @@ -377,9 +377,17 @@ void Sync(bool force) { if (now >= g_noticeUntilMs) g_notice.clear(); g_sNotice.Set(g_notice); - bool outdated = false; - const std::string latest = sm::LatestVersionLine(&outdated); - g_sUpdate.Set(outdated ? latest : std::string()); + // The pane shows the update line only when a newer build exists, composed here in the player's + // language; the stored line stays English for the menu's label and the log. + const std::string superseding = sm::LatestSuperseding(); + std::string update; + if (!superseding.empty()) { + char line[256]; + if (l10n::Fmt(line, sizeof(line), l10n::T("%1$s -- UPDATE AVAILABLE: %2$s"), sm::DisplayVersion().c_str(), + superseding.c_str()) >= 0) + update = line; + } + g_sUpdate.Set(update); // The name everyone else will see, phrased as an answer rather than as a claim about what // the player is doing (see the status pane's note). The Change name button edits exactly diff --git a/src/votv-coop/src/ui/voice_icons.cpp b/src/votv-coop/src/ui/voice_icons.cpp index b215b0d55..33abfda0d 100644 --- a/src/votv-coop/src/ui/voice_icons.cpp +++ b/src/votv-coop/src/ui/voice_icons.cpp @@ -64,7 +64,6 @@ void Draw(ImDrawList* dl, ImVec2 c, float size, VoiceIcon icon, float alpha) { const float h = size; const float t = (h * 0.10f < 1.0f) ? 1.0f : h * 0.10f; const int a = static_cast(alpha * 255.0f); - const ImU32 white = IM_COL32(235, 240, 245, a); const ImU32 green = IM_COL32(120, 235, 140, a); const ImU32 red = IM_COL32(245, 105, 95, a); const ImU32 grey = IM_COL32(150, 155, 162, a); From 8cca2cb5217a18fae03d38fdd61fa855130dc17e Mon Sep 17 00:00:00 2001 From: Slintchen <80808676+Slintchen@users.noreply.github.com> Date: Thu, 1 Oct 2026 13:43:24 +0800 Subject: [PATCH 092/306] [l10n] the Chinese pack, the translator's README and the code table What a translator is handed: a three-step README, a table of language codes, and a finished Simplified Chinese pack of 217 lines, drafted with a local model and reviewed in game. The net of the commit below: its data and docs, which build nothing, byte for byte; the layer they were written for is rebuilt on gettext catalogues, and the next commits carry the translations and the guide into it. Co-Authored-By: Claude Opus 5.5 (cherry picked from commit cd0460ec3ba6e3faa337366a5ba50f1cc2fa5eba) --- src/votv-coop/trans/LANGUAGE-CODES.md | 44 ++++++ src/votv-coop/trans/README.md | 57 +++++++ src/votv-coop/trans/ZH-CN.json | 219 ++++++++++++++++++++++++++ 3 files changed, 320 insertions(+) create mode 100644 src/votv-coop/trans/LANGUAGE-CODES.md create mode 100644 src/votv-coop/trans/README.md create mode 100644 src/votv-coop/trans/ZH-CN.json diff --git a/src/votv-coop/trans/LANGUAGE-CODES.md b/src/votv-coop/trans/LANGUAGE-CODES.md new file mode 100644 index 000000000..ec2f3cac0 --- /dev/null +++ b/src/votv-coop/trans/LANGUAGE-CODES.md @@ -0,0 +1,44 @@ +# Language codes + +**The file name IS the tag.** Copy `example.json` to the name from the tables below and translate it. + +The tag is a BCP-47 language code, and it is what Windows reports for your system language -- +which is what the mod compares against, so an exact match is the common case. + +## Chinese + +| File | Language | +|---|---| +| `zh-CN.json` | Simplified Chinese (what a Chinese Windows returns) | +| `zh.json` | covers every Chinese locale as a fallback when the exact name is absent | +| `zh-TW.json` | Traditional Chinese (Taiwan) | +| `zh-HK.json` | Traditional Chinese (Hong Kong) | + +## Other common languages + +| File | Language | | File | Language | +|---|---|---|---|---| +| `en.json` | English (built in) | | `tr.json` | Turkish | +| `ja.json` | Japanese | | `hu.json` | Hungarian | +| `ko.json` | Korean | | `ro.json` | Romanian | +| `de.json` | German | | `vi.json` | Vietnamese | +| `fr.json` | French | | `th.json` | Thai | +| `es.json` | Spanish | | `id.json` | Indonesian | +| `pt-BR.json` | Brazilian Portuguese | | `ms.json` | Malay | +| `pt.json` | Portuguese | | `hi.json` | Hindi | +| `it.json` | Italian | | `ar.json` | Arabic | +| `ru.json` | Russian | | `fa.json` | Persian | +| `uk.json` | Ukrainian | | `he.json` | Hebrew | +| `pl.json` | Polish | | `cs.json` | Czech | +| `nl.json` | Dutch | | `sv.json` | Swedish | +| `da.json` | Danish | | `fi.json` | Finnish | +| `no.json` | Norwegian | | `el.json` | Greek | + +Any tag Windows can report works: the loader tries the exact name first, then the part before the +dash, then English. + +## Notes + +- The file must be **UTF-8**. A byte-order mark is tolerated and stripped, but plain UTF-8 is better. +- The mod only draws scripts its font layer can reach. CJK (`zh`, `ja`, `ko`) is wired up and + merges a system face; for other scripts, check the log -- a missing glyph is reported. diff --git a/src/votv-coop/trans/README.md b/src/votv-coop/trans/README.md new file mode 100644 index 000000000..c187efbf6 --- /dev/null +++ b/src/votv-coop/trans/README.md @@ -0,0 +1,57 @@ +# Translating Multivoid + +Turn the mod's interface into your own language -- **no programming, no rebuild**: copy one file, +translate it, rename it, drop it beside the game, restart. + +## Three steps + +1. Find the `trans` folder in your game install: + + \WindowsNoEditor\VotV\Binaries\Win64\trans\ + + It sits next to `multivoid.ini` and `multivoid.log`. If it does not exist, create it. + +2. Copy `example.json` from this folder into that one, rename it to your language code + (see LANGUAGE-CODES.md -- e.g. `zh-CN.json`), and replace the English on the **right** with + your language. **Do not touch the left-hand side (the key).** + + { + "Multiplayer": "...", + "Host": "..." + } + +3. Restart the game. The interface is now in your language. + +## How your file is chosen + +At start-up the mod looks, in this order, for: + +1. `ui.language` in `multivoid.ini` (default `auto`, i.e. "not specified") +2. your **Windows system language** (`zh-CN`, `ja-JP`, `de-DE`, ...) +3. the exact name first (`zh-CN.json`), then the primary subtag (`zh.json`) +4. and if neither exists, the **built-in English** -- the UI is never blank and never shows a key name + +To see what it settled on, open `multivoid.log` and search for `i18n:`. That one line prints the +tag, the file it read, how many entries it kept, and which folder it searched. + +## What must stay exactly as it is + +| Item | Example | Why | +|---|---|---| +| printf placeholders | %s %d %zu %.0f | the code fills these in. The **count must match**; their order may follow your language | +| `##` markers | the `##banlist` in `"Ban List##banlist"` | that is ImGui's internal widget id; changing it breaks the panel | +| positional words | "set a password below" | English's below/above describes English's layout -- **check the real screen** instead of translating it literally | + +A wrong translation cannot harm anything: that single line falls back to English and a warning is +written to the log. An empty value means "not translated yet". + +## For maintainers + +- `example.json` is **generated** -- do not edit it by hand: + - regenerate: `python src/votv-coop/i18n-tools/extract.py` + - check template and source agree (CI-friendly): `... extract.py --check` +- draft a pack with a **local** model (no API cost): `... translate.py --lang zh-CN --tag ZH-CN` + It validates the placeholders and `##` markers of every line, and resumes after an interruption. +- wire new strings to the lookup: `... apply.py` (dry run; add `--write` to edit files). It only + touches strings that are already in the template, and skips `constexpr` tables -- a constant + expression cannot call a function, so those are translated where they are drawn. diff --git a/src/votv-coop/trans/ZH-CN.json b/src/votv-coop/trans/ZH-CN.json new file mode 100644 index 000000000..9c0c3a6f6 --- /dev/null +++ b/src/votv-coop/trans/ZH-CN.json @@ -0,0 +1,219 @@ +{ + "%s = '%s' -- not a known setting (typo, or a retired key).": "%s = '%s' —— 未知设置(拼错了,或已弃用)。", + "(both peers)": "双方:", + "(class + prop visual row)": "(类别 + 道具视觉行)", + "(host only)": "(仅房主)", + "(host spawns + syncs)": "主机生成 + 同步", + "(host/local only)": "(仅本地/房主)", + "(labels projected onto nearby objects)": "物体附近的投影标签", + "(lines between every bone of an ACTIVE ragdoll body)": "(当前活跃布娃娃体每根骨骼之间的连线)", + "(local balance -- afford drone orders)": "(本地平衡——可负担无人机订单)", + "(mirror test: watch the client radar)": "镜像测试:查看客户端雷达", + "(no description)": "无描述", + "(on-screen overlay)": "(屏幕叠加层)", + "(or press Q while enabled)": "(或在启用状态下按 Q)", + "(sim/rest + static/frozen/sleep)": "(模拟/休息 + 静态/冰冻/睡眠)", + "(sun %.3f)": "(太阳 %.3f)", + "(you)": "(你)", + "A compact live panel in the top-right corner: receive/send rate right\nnow (wire-level, includes protocol overhead), total downloaded/uploaded\nthis session, packets/s, peers + ping, and a 60 s rate graph.\nWorks for host and clients. Saved to multivoid.ini (ui.netstats).": "右上角实时面板:收发速率(链路层,含协议开销)、本次会话累计下载/上传、包/秒、玩家数与延迟,以及 60 秒速率曲线。房主和客户端都能用。保存到 multivoid.ini(ui.netstats)。", + "A pak that carries no skin (another mod's) is dropped from this list once the game has been asked -- it is not a skin.": "不含皮肤的 pak(其他模组的)在游戏扫描后会被剔除——它不是皮肤。", + "AUTOMATIC (recommended)": "自动(推荐)", + "AUTOMATIC (recommended)": "自动(推荐)", + "Anyone can find your game in the list.": "任何人都能在列表里看到你的房间。", + "Anyone can join": "任何人都可以加入", + "Anyone with this can join. Give it out the way you would give out an invite link.": "拿到它就能加入。像发邀请链接一样发出去就行。", + "Anyone with this can join. On this connection type give your friends your address and this password -- they type both into Direct Connect.": "拿到地址和密码就能加入。这种连接方式下,把地址和密码发给好友,让他们在「直连」里分别填入。", + "Arm + complete NOW: fires the event (clients get the arm as usual), then\ndrives %s's own overlap handler with your pawn -- the same dispatch a real\nwalk-in performs (native class filter + N bookkeeping).%s": "布防 + 立即完成:触发事件(客户端照常收到布防),然后用你的角色驱动 %s 自身的重叠处理——与真人走进来完全相同的分发(原生类别过滤 + N 记账)。%s", + "Automatic games are always listed -- the list is how friends find you. Set a password below to keep strangers out.": "自动模式的房间总会列出——好友正是靠它找到你。想挡住陌生人,就设个密码。", + "Ban (permanent)": "封禁(永久)", + "Busy -- another host or join is already starting.": "正忙——另一个开房或加入操作正在进行。", + "CONNECTION": "连接", + "Cancel": "取消", + "Change name": "修改名称", + "Click on the name of the rule to see its description.": "点击规则名称查看说明。", + "Colors your nick everywhere it shows -- nameplate, chat, player list --": "给所有显示昵称的地方上色——名牌、聊天、玩家列表——", + "Connect": "连接", + "Connection:": "连接:", + "Connection: ": "连接:", + "Connects directly when your network allows it, relays automatically": "网络允许时直连,否则自动中继", + "Could not generate a password on this system -- the lock stays off.": "本机无法生成密码——锁定未启用。", + "Could not rewrite multivoid.ini (it is missing, or the write failed) -- nothing was changed.": "无法重写 multivoid.ini(文件不存在或写入失败)——未做任何改动。", + "Could not rewrite multivoid.ini (locked or failing) -- nothing was changed. Close whatever holds the file and press Tidy up again.": "无法重写 multivoid.ini(被锁定或失败)——什么都没有改动。关掉占用该文件的程序,再按一次「整理」。", + "Could not rewrite multivoid.ini: it holds a zero byte or a UTF-16 byte-order mark -- nothing was changed. Save it again as UTF-8 and press Tidy up again.": "无法重写 multivoid.ini:它是零字节文件或带 UTF-16 字节序标记。请另存为 UTF-8,再点一次「整理」。", + "Custom nickname color": "自定义昵称颜色", + "DIRECT (you forward a port)": "直连(P2P)", + "DIRECT (you forward a port)": "直连(P2P)", + "Developer tools are disabled while connected as a client (host-only).": "以客户端身份连接时,开发者工具不可用(仅房主可用)。", + "Developer tools are hidden. Set [dev] devkeys=1 in multivoid.ini to show them.": "开发者工具已隐藏。在 multivoid.ini 里设 [dev] devkeys=1 可显示。", + "Device changes apply immediately (the voice engine\nreopens on the game's next tick).": "设备改动立即生效(语音引擎在下一帧重开)。", + "Direct connect": "直连", + "Direct connect:": "直连:", + "Disconnected now and blocked by IP on reconnect.": "已断开;重连时会被 IP 封禁拦截。", + "Enables the sandbox Q spawn menu in story mode.": "在故事模式下启用沙盒 Q 生成菜单。", + "Freecam": "自由视角", + "Friends on your own network can join with no forwarding at all.": "同一局域网的好友,无需任何端口转发即可加入。", + "Friends reach your PC. No server involved.": "朋友直连你的电脑,不经过任何服务器。", + "Game: %s": "游戏:%s", + "Gamemode:": "游戏模式:", + "GetDesiredSize": "获取期望大小", + "Graphics API: %s": "图形API:%s", + "Grouped by strict": "按严格分组", + "HOME also toggles - WASD move, Space/Ctrl up/down,": "HOME也切换- WASD移动,Space/Ctrl上下", + "Health / progress": "血量 / 进度", + "Hidden": "隐藏", + "Hidden: nothing is sent to the master server at any point.": "隐藏:全程不会向主服务器发送任何信息。", + "Hide from server browser (friends Direct Connect by IP)": "不在服务器浏览器中显示(好友用 IP 直连)", + "Hold the key to talk. Change the key via voice.ptt_key\nin multivoid.ini (single letter or a virtual-key number).": "按住该键说话。可在 multivoid.ini 的 voice.ptt_key 修改(单个字母或虚拟键码)。", + "Host Game - choose a world###coop_save_picker": "创建房间 - 选择一个世界###coop_save_picker", + "Host game": "创建房间", + "Host selected save": "以所选存档开房", + "Join": "加入", + "Kick": "踢出", + "Launch anyways": "仍然启动", + "Locked": "已锁定", + "MOD INSTALL PROBLEM": "MOD 安装问题", + "MULTIVOID SETTINGS CHECK": "MULTIVOID 设置检查", + "Master server: ": "主服务器:", + "Mic gain": "麦克风增益", + "Multiplayer": "多人模式", + "Multivoid - Change name": "Multivoid - 修改名称", + "Multivoid - Direct connect": "Multivoid - 直连", + "Multivoid - Host Game": "Multivoid - 创建房间", + "Multivoid - Password": "Multivoid - 密码", + "Multivoid - Server Browser": "Multivoid - 服务器浏览器", + "Multivoid - Session settings": "Multivoid - 会话设置", + "Mute for me": "为我静音", + "Mute my microphone": "静音我的麦克风", + "Net identity": "网络身份", + "New Game & Host": "新游戏并开房", + "New Game (Story):": "新游戏(故事):", + "New game": "新游戏", + "Next": "下一步", + "No active session.": "当前没有会话。", + "No banned players.": "暂无被封禁的玩家。", + "No players.": "没有玩家。", + "No remote players connected.": "没有其他玩家连接。", + "No stored IP for this player (P2P join) --\nnothing the IP ban filter could enforce against.": "该玩家没有记录 IP(P2P 加入)——IP 封禁无从执行。", + "No tools here yet -- coming soon.": "这里还没有工具 -- 近期推出。", + "Nobody in the registry yet (players are recorded when they join).": "名单里还没有人(玩家加入时才会记录)。", + "Object names": "对象名称", + "Off = your floating name/health bar disappears on every peer's": "关闭 = 你的浮动名字/血条在其他玩家屏幕上消失", + "Only friends you give your address to.": "只有拿到你地址的好友才能加入。", + "Only players you give the password to.": "只有输入密码才能加入。", + "Open spawn menu now": "立即打开生成菜单", + "Overlay fonts (per surface):": "叠加层字体(分界面):", + "PLAYERS": "玩家", + "Password": "密码", + "Password (leave empty if the server has none)": "密码(服务器没设密码就留空)", + "Password required": "需要密码", + "Peer action notifications": "同伴动作提示", + "Peers WITHOUT that pak see the default kel body instead.": "没装该 pak 的玩家会看到默认 kel 身体。", + "Peers: %d": "同伴:%d", + "Peers: %d, worst ping %d ms": "同伴:%d,最差延迟 %d ms", + "Permanently ban %s?": "永久封禁 %s?", + "Physics state": "物理状态", + "Pick a save to host, or start a New Game:": "选一个存档开房,或开始新游戏:", + "Position / camera readout": "位置 / 相机读取", + "Prop spawn menu in story mode (Q)": "故事模式道具生成菜单 (Q)", + "Ragdoll bone skeleton": "骨骼骨架", + "Read-only. The host's save sets the rules for everyone.": "只读。房主的存档决定所有人的规则。", + "Refresh": "刷新", + "Refresh list": "刷新列表", + "Requires UDP port %ld forwarded to this PC. Friends join from": "需要把 UDP 端口 %ld 转发到本机。好友从", + "Rescan devices": "重新扫描设备", + "Restore vitals (food / sleep / health)": "恢复生命体征(食物 / 睡眠 / 血量)", + "Return": "返回", + "Rewrites multivoid.ini in the standard layout: [net] first, [dev]\nlast, each setting under its section, exact-duplicate lines merged.\nUnknown settings and invalid values are commented out (kept in the\nfile, just disabled) -- that resolves their warnings above. An\ninvalid value of a setting that refuses instead (%s) is kept as\nit is: disabling it would switch that setting to its default.\nConflicting duplicates are NEVER auto-resolved -- use the keep-line\nbuttons above. Your comments travel with their settings.": "按标准布局重写 multivoid.ini:[net] 在前、[dev] 在后,每个设置归到自己的分区,完全重复的行合并。未知设置与无效值会被注释掉(保留在文件里,只是禁用),这样就清掉了上面的警告。遇到「拒绝回退」的设置而值无效时(%s),保持原样——禁用它等于切回默认值。互相冲突的重复行绝不会自动裁决,请用上面的「保留哪一行」按钮。你的注释会跟着对应设置一起搬。", + "SERVER LIST": "服务器列表", + "Saved to multivoid.ini (ui.font.menu/chat/net/nameplate/toast,": "保存到multivoid.ini (ui.font.menu/chat/net/nameplate/toast,", + "Scales the WHOLE overlay (menus, chat, nameplates) on top of the\nautomatic resolution scale. Applies when you release the slider\n(the fonts re-bake). Saved to multivoid.ini (ui.scale).": "按自动分辨率缩放整个叠加层(菜单、聊天、名牌)。松开滑块时生效(字体会重新烘焙)。保存至 multivoid.ini(ui.scale)。", + "Select a category on the left.": "在左侧选择类别。", + "Server address": "服务器地址", + "Server browser": "服务器浏览器", + "Server info:": "服务器信息:", + "Server password": "服务器密码", + "Set clock": "设置时钟", + "Set stamina low": "降低耐力", + "Shift fast, wheel speed, MMB bring player": "Shift 快速切换、滚轮调速、中键把玩家拉过来", + "Show a chat line when another player does a shared action": "其他玩家做出共享操作时在聊天里显示一行", + "Show in server browser": "显示在服务器浏览器中", + "Show my nameplate to other players": "向其他玩家显示我的名牌", + "Show network stats overlay": "显示网络状态叠加层", + "Skins come from converter .pak files under Content/Paks/LogicMods (any subfolder). One pak may carry several skins -- the four scientists ship together in scientists.pak.": "皮肤来自 Content/Paks/LogicMods 下的 .pak 文件(任意子文件夹)。一个 pak 可含多个皮肤——四个科学家就打包在 scientists.pak 里。", + "Snow": "雪", + "Spawn Eyer (in front)": "生成 Eyer(前方)", + "Spawn kerfurOmega (in front)": "生成 kerfurOmega(前方)", + "Spawn killerWisp (in front)": "生成 killerWisp(前方)", + "Spawn killerWisp ON client (coop kill test)": "在客户端生成 killerWisp(联机击杀测试)", + "Spawn ventCrawler (in front)": "生成 ventCrawler(前方)", + "Speech louder than this transmits. Watch the mic\nmeter: set the slider just above your room's noise.": "只有比这个阈值响的语音才会发送。看着麦克风条:把滑块调到略高于环境噪音的位置。", + "Sun position": "太阳位置", + "Teleport": "传送", + "Teleport clients to me": "把所有客户端传送到我这里", + "Teleport to me": "传送到我这里", + "This save was made in another version of the game.": "该存档来自另一个游戏版本。", + "Threshold": "阈值", + "Tidied: %d duplicate line(s) merged, %d setting(s) placed under their sections, %d unknown/invalid line(s) commented out. Anything still listed above needs a choice or can't be fixed from the file.": "整理完成:合并 %d 条重复行,%d 个设置归位,%d 条未知或无效行已注释。上面还列着的,需要你选择,或无法从文件里修复。", + "Type a password, or choose \"Anyone can join\".": "任何人都可以加入", + "Unban": "解除封禁", + "Up: %s now, %s total, %llu packets": "上传:当前 %s,总计 %s,%llu 个包", + "Update list": "更新列表", + "V: voice settings": "V: 语音设置", + "VOTV Coop - Menu (F1)": "VOTV Coop - 菜单 (F1)", + "Visual only (sun angle). The clock above is what events/save follow.": "仅视觉(太阳角度)。上面那个时钟才是事件/存档跟随的。", + "Voice activation": "语音激活", + "Voice chat is turned off (voice.enabled=0 in multivoid.ini). Set it to 1 and restart the game to use voice.": "语音聊天已关闭(multivoid.ini 里 voice.enabled=0)。改成 1 并重启游戏即可使用。", + "Voice chat###coop_voice_panel": "语音聊天###coop_voice_panel", + "Voice volume": "语音音量", + "WHO MAY JOIN": "谁可以加入", + "WORLD": "世界", + "We introduce you. Nothing to set up.": "我们替你引荐,无需任何设置。", + "World clock not resolved yet (enter a world).": "世界时钟尚未确定(先进入一个世界)。", + "World object overlay": "世界物体叠加层", + "World: ": "世界:", + "World: a new game": "世界:新游戏", + "Your name": "你的名字", + "Your name:": "你的名字:", + "Your session is open to everyone who finds it.": "任何人都可以直接加入。", + "Your session:": "你的房间:", + "[gate: volume ?]": "[gate: volume?]", + "[volume-gated]": "音量闸门", + "banned": "已封禁", + "category. 'day N' = unlock day. The cyan TIME column is the native trigger time-of-": "类别。'day N' = 解锁日。青色 TIME 列是原生触发时刻——", + "current: %s": "当前:%s", + "day (HH:MM anchor) / 'trigger' (story/build) / 'rep' (ariral-reputation prank pool).": "日(HH:MM 锚点)/ 'trigger'(剧情/建造)/ 'rep'(ariral 声望恶作剧池)。", + "direct (you forward the port)": "直连(P2P)", + "each with its own default). Fixedsys (VOTV) = game terminal pixel": "每个都有其默认值)。Fixedsys (VOTV) = 游戏终端像素", + "friends find you. Set a password below to keep strangers out.": "好友正是靠它找到你。想挡住陌生人,就设个密码。", + "give your friends your address and this password -- they type both into Direct Connect.": "把你的地址和这个密码发给好友,让他们在「直连」里分别填入。", + "host or host:port": "主机或主机:端口", + "in-game from the scoreboard once your friends have joined.": "好友加入后,可在游戏内用计分板操作。", + "instantly (drives the volume's own overlap with your pawn -- the native walk-in).": "立即(驱动音量与你的角色自身的重叠——等同真人走进去)。", + "multivoid.ini exists but could not be read (locked or failing).": "multivoid.ini 存在但读不出来(被占用或读取失败)。", + "multivoid.ini holds a zero byte or starts with a UTF-16 byte-order mark, as a file saved as UTF-16 (\"Unicode\") does. Save it again as UTF-8.": "multivoid.ini 是零字节文件,或以 UTF-16 字节序标记开头(即用 UTF-16 保存过)。请另存为 UTF-8。", + "multivoid.ini was checked against the known settings at launch.\nNothing was changed automatically -- broken values fall back to\ntheir defaults for this session, except the settings that refuse\ninstead (%s). The buttons below edit the file only when you click\nthem. This notice returns next launch while anything is still off.": "multivoid.ini 在启动时已与已知设置核对过。没有自动改动任何东西——出错的值本次会话回退到默认值,但那些「拒绝回退」的设置除外(%s)。下面的按钮只有在你点击时才编辑文件。只要还有问题,这条提示下次启动还会出现。", + "no IP": "无 IP", + "orange = your own ragdoll, cyan = a remote peer's mirror body.": "橙色 = 你自己的布娃娃,青色 = 远程同伴的镜像身体。", + "otherwise. Works without any router setup. Always listed -- hide it": "无需任何路由器设置。始终列出——想隐藏它", + "say:": "说:", + "server name": "服务器名称", + "sessions (ui.chat.peer_actions).": "会话(ui.chat.peer_actions)", + "strangers out.": "挡住陌生人。", + "the registry remembers every player who ever joined this host.": "名单会记住每个曾加入过这个房主的人。", + "the server browser or Direct Connect. Not sure? Use AUTO.": "服务器浏览器或「直连」。不确定?用自动就行。", + "they type both into Direct Connect.": "在「直连」里分别填入这两项。", + "would give out an invite link.": "就像发邀请链接一样。", + "your name": "你的名字", + "MULTIPLAYER###coop_browser": "多人游戏###coop_browser", + "Select a server": "选择一个服务器", + "server": "个服务器", + "servers": "个服务器", + "updated ": "更新于 ", + "just now": "刚刚", + "Your name: ": "你的名字:", + "Showing the ": "正在显示 ", + "server list.": "服务器列表。", + "Back": "返回", + "s ago": "秒前", + "Host": "房主" +} From 42b7dd61c7c5834ec236cb843cddaaa1649c6520 Mon Sep 17 00:00:00 2001 From: pelmentor Date: Sat, 10 Oct 2026 07:18:36 +0600 Subject: [PATCH 093/306] [l10n] the translation template, and the gate on its call sites locale/multivoid.pot holds every msgid the mod looks up, 421 of them, extracted from the files that may hold player text: a literal allowlist in .github/ci/l10n_pot.py, which reads C++ as tokens, joins adjacent literals and writes the entries sorted, without line numbers, so the template changes only when a string does. Its --check, in repo-gates, fails a stale template, an extraction outside the allowlist, a widget taking a translated label without Label, an English format the grammar refuses, a Fmt call whose argument count is not its msgid's, and a shipped pack holding a msgid the template does not. Each rule was seen failing on a planted call before this commit. Co-Authored-By: Claude Opus 5.5 --- .github/ci/l10n_pot.py | 421 +++++++ .github/workflows/repo-gates.yml | 3 + docs/code-map.md | 5 +- src/votv-coop/locale/multivoid.pot | 1876 ++++++++++++++++++++++++++++ 4 files changed, 2303 insertions(+), 2 deletions(-) create mode 100644 .github/ci/l10n_pot.py create mode 100644 src/votv-coop/locale/multivoid.pot diff --git a/.github/ci/l10n_pot.py b/.github/ci/l10n_pot.py new file mode 100644 index 000000000..5cccf200d --- /dev/null +++ b/.github/ci/l10n_pot.py @@ -0,0 +1,421 @@ +#!/usr/bin/env python3 +"""l10n_pot -- the mod's translation template, and the gate that keeps the call sites honest. + +The template (src/votv-coop/locale/multivoid.pot) is every msgid the mod looks up, extracted from the files that may +hold player text: the allowlist below, kept by hand beside the translation layer's design and never guessed. A +contributor without the maintainer's local tools regenerates it with this script; MTA runs xgettext for the same +job, and ours is a script because the markers inside the config tables and the allowlist are ours. + +Extraction reads C++ as tokens (comments and strings skipped as the compiler skips them), takes `l10n::T`, +`l10n::Tc`, `l10n::Tn`, `L10N_MARK`, `L10N_MARK_C` and `L10N_MARK_N` whose arguments are string literals, joins +adjacent literals and decodes escapes as the compiler does, and writes entries sorted by (msgctxt, msgid) with +file-name references and no line numbers, so the template changes only when a string does. A non-literal argument +(a drawer's `T(entry.name)`) is not extracted: its literal was marked where it is defined. + +Usage: + l10n_pot.py writes the template + l10n_pot.py --check the gate: exits 1 on a stale template or on any rule below + +The gate's rules (the translation layer's design, WP-4): + 1. an extraction call or marker with a literal argument in a file outside the allowlist; + 2. an id-bearing ImGui widget (a function of imgui.h whose first parameter is label, name or str_id) whose first + argument is an l10n::T family call rather than an l10n::Label; + 3. an English msgid with a '%' that the conversion grammar refuses, or a plural pair whose msgids differ in their + conversions; + 4. an l10n::Fmt or l10n::FmtSpan call whose format is an l10n::T family call on literals and whose argument count + differs from that msgid's conversions; + 5. a shipped pack (locale/*.po) holding a msgid the template does not. +""" + +import pathlib +import re +import sys + +ROOT = pathlib.Path(__file__).resolve().parents[2] +MOD = ROOT / "src" / "votv-coop" +TEMPLATE = MOD / "locale" / "multivoid.pot" +IMGUI_H = MOD / "third_party" / "imgui" / "imgui.h" + +# The files that may hold player text (the design's D6), relative to src/votv-coop/. Edited with each call-site +# sheet; a file outside it may not extract. +ALLOWLIST = [ + "include/coop/commands/command_dispatcher.h", + "include/coop/config/config_registry_rows.inc", + "src/coop/bug_report/report_bundle.cpp", + "src/coop/bug_report/report_files.cpp", + "src/coop/bug_report/report_form.cpp", + "src/coop/commands/command_sync.cpp", + "src/coop/comms/peer_action_feed.cpp", + "src/coop/dispatch/event_feed.cpp", + "src/coop/net/end_reason.cpp", + "src/coop/player/local_body.cpp", + "src/coop/player/nameplate.cpp", + "src/coop/player/nick_color.cpp", + "src/coop/player/sleep_sync.cpp", + "src/coop/session/player_handshake.cpp", + "src/coop/session/player_handshake_prefs.cpp", + "src/coop/session/player_handshake_version.cpp", + "src/coop/session/server_settings_sync.cpp", + "src/ui/admin_panel.cpp", + "src/ui/browser_input_screens.cpp", + "src/ui/bug_report_pane.cpp", + "src/ui/config_review_panel.cpp", + "src/ui/console.cpp", + "src/ui/dev_menu.cpp", + "src/ui/end_reason_dialog.cpp", + "src/ui/fonts.cpp", + "src/ui/hud.cpp", + "src/ui/join_curtain.cpp", + "src/ui/link_format.cpp", + "src/ui/loading_screen.cpp", + "src/ui/net_stats_panel.cpp", + "src/ui/scoreboard.cpp", + "src/ui/server_browser_actions.cpp", + "src/ui/server_browser_native.cpp", + "src/ui/server_browser_panels.cpp", + "src/ui/server_browser_rows.cpp", + "src/ui/server_settings_pane.cpp", + "src/ui/skins_panel.cpp", + "src/ui/voice_icons.cpp", + "src/ui/voice_panel.cpp", + "src/ui/world_rules_panel.cpp", +] +# The layer itself, which looks strings up on purpose and extracts nothing. +EXEMPT_DIRS = ("src/l10n/", "include/l10n/") + +EXTRACT = {"l10n::T": ("id",), "l10n::Tc": ("ctx", "id"), "l10n::Tn": ("id", "plural", None), + "L10N_MARK": ("id",), "L10N_MARK_C": ("ctx", "id"), "L10N_MARK_N": ("id", "plural")} +T_FAMILY = ("l10n::T", "l10n::Tc", "l10n::Tn") + +HEADER = """# The Multivoid interface's translation template. Generated by .github/ci/l10n_pot.py; never edited by hand. +# A translation is a copy of this file named .po (zh_CN.po), with msgstr filled in. +msgid "" +msgstr "" +"Project-Id-Version: Multivoid\\n" +"Content-Type: text/plain; charset=UTF-8\\n" +"Content-Transfer-Encoding: 8bit\\n" +"Plural-Forms: nplurals=INTEGER; plural=EXPRESSION;\\n" +""" + + +# ---- tokens ------------------------------------------------------------------------------------------------ + +TOKEN = re.compile(r""" + (?P//[^\n]*|/\*.*?\*/) + | (?P(?:u8|u|U|L)?R"(?P[^()\\ ]{0,16})\((?P.*?)\)(?P=delim)") + | (?P(?:u8|u|U|L)?"(?:[^"\\\n]|\\.)*") + | (?P(?:u8|u|U|L)?'(?:[^'\\\n]|\\.)*') + | (?P[A-Za-z_][A-Za-z_0-9]*(?:::[A-Za-z_][A-Za-z_0-9]*)*) + | (?P[()\[\]{},;]) + | (?P\S) +""", re.S | re.X) + + +def decode(literal): + """A narrow C++ string literal's bytes, as the compiler decodes them (the escapes our sources use).""" + body = literal[literal.index('"') + 1:-1] + out = bytearray() + i = 0 + simple = {"n": 10, "t": 9, "r": 13, "\\": 92, '"': 34, "'": 39, "a": 7, "b": 8, "f": 12, "v": 11, "0": 0} + while i < len(body): + c = body[i] + if c != "\\": + out += c.encode("utf-8") + i += 1 + continue + e = body[i + 1] + if e == "x": + j = i + 2 + while j < len(body) and body[j] in "0123456789abcdefABCDEF": + j += 1 + out.append(int(body[i + 2:j], 16) & 0xFF) + i = j + elif e == "u" or e == "U": + n = 4 if e == "u" else 8 + out += chr(int(body[i + 2:i + 2 + n], 16)).encode("utf-8") + i += 2 + n + elif e in "01234567" and not (e == "0" and (i + 2 >= len(body) or body[i + 2] not in "01234567")): + j = i + 1 + while j < len(body) and j < i + 4 and body[j] in "01234567": + j += 1 + out.append(int(body[i + 1:j], 8) & 0xFF) + i = j + else: + out.append(simple.get(e, ord(e))) + i += 2 + try: + return out.decode("utf-8") + except UnicodeDecodeError: + return None # bytes a test feeds on purpose: never a msgid + + +def tokens(text): + for m in TOKEN.finditer(text): + kind = m.lastgroup + if kind in ("comment",): + continue + if kind == "raw": + yield ("string", None, m.start()) # a raw string is never a msgid here + elif kind == "string": + lit = m.group("string") + yield ("string", None if lit[0] in "uUL" and not lit.startswith("u8") else decode(lit), m.start()) + elif kind == "delim" or kind == "rawbody": + continue + else: + yield (kind, m.group(kind), m.start()) + + +def calls(toks): + """(name, args, position) for every identifier followed by '(', args as token lists split at top-level commas.""" + for i, (kind, val, pos) in enumerate(toks): + if kind != "ident" or i + 1 >= len(toks) or toks[i + 1][1] != "(": + continue + depth, j, args, cur = 0, i + 1, [], [] + while j < len(toks): + k, v, _ = toks[j] + if k == "punct" and v in "([{": + depth += 1 + if depth > 1: + cur.append(toks[j]) + elif k == "punct" and v in ")]}": + depth -= 1 + if depth == 0: + args.append(cur) + break + cur.append(toks[j]) + elif k == "punct" and v == "," and depth == 1: + args.append(cur) + cur = [] + else: + cur.append(toks[j]) + j += 1 + yield val, args, pos, i + + +def literal(arg): + """The joined value of an argument made only of adjacent string literals, else None.""" + if not arg or any(k != "string" for k, _, _ in arg) or any(v is None for _, v, _ in arg): + return None + return "".join(v for _, v, _ in arg) + + +# ---- the conversion grammar (the layer's printf_check, in Python) ------------------------------------------- + +CONV = re.compile(r"%(?:(?P[0-9]{1,3})\$)?(?P[-+ #0]*)(?P[0-9]*)(?:\.(?P[0-9]*))?" + r"(?Pll|l|z)?(?P.)?") + + +def conversions(fmt): + """[(argno, type, length)] or raises ValueError with the reason, for a msgid with a '%'.""" + out, numbered, i = [], None, 0 + while i < len(fmt): + if fmt[i] != "%": + i += 1 + continue + if fmt[i + 1:i + 2] == "%": + i += 2 + continue + m = CONV.match(fmt, i) + t = m.group("type") + flags = m.group("flags") + if t is None: + raise ValueError("a conversion without its type") + if t == "n": + raise ValueError("a '%n' conversion") + if t not in "diuxXofFeEgGs": + raise ValueError("a conversion type outside d i u x X o f F e E g G s") + if len(set(flags)) != len(flags): + raise ValueError("a repeated flag") + if len(m.group("width")) > 2 or (m.group("prec") or "").__len__() > 2: + raise ValueError("a width or precision of more than two digits") + if m.group("prec") is not None and t == "s": + raise ValueError("a precision on %s") + if m.group("len") and t not in "diuxXo": + raise ValueError("a length modifier on a non-integer type") + if "0" in flags and t == "s": + raise ValueError("a '0' flag on %s") + if ("+" in flags or " " in flags) and t not in "difFeEgG": + raise ValueError("a '+' or ' ' flag on an unsigned or string type") + if "#" in flags and t not in "xXofFeEgG": + raise ValueError("a '#' flag on a type it has no form for") + has = m.group("num") is not None + if numbered is None: + numbered = has + elif numbered != has: + raise ValueError("numbered and unnumbered conversions mixed") + arg = int(m.group("num")) if has else len(out) + 1 + out.append((arg, "d" if t == "i" else t, m.group("len") or "")) + i = m.end() + nums = sorted(a for a, _, _ in out) + if nums != list(range(1, len(out) + 1)): + raise ValueError("an argument read twice or a gap in the numbers") + if len(out) > 16: + raise ValueError("more than 16 conversions") + return out + + +def signature(fmt): + return sorted(conversions(fmt)) if "%" in fmt else [] + + +# ---- the scan ------------------------------------------------------------------------------------------------- + +def widget_names(): + text = IMGUI_H.read_text(encoding="utf-8", errors="replace") + return set(re.findall(r"IMGUI_API\s+[\w\s\*]*?\b(\w+)\s*\(\s*const\s+char\s*\*\s*(?:label|name|str_id)\b", text)) + + +def scan(): + entries = {} # (ctx, id) -> {"plural": str|None, "refs": set} + problems = [] + widgets = widget_names() + sources = sorted(p for p in MOD.rglob("*") if p.suffix in (".cpp", ".h", ".inc") and "third_party" not in p.parts) + for path in sources: + rel = path.relative_to(MOD).as_posix() + if rel.startswith(EXEMPT_DIRS): + continue + text = path.read_text(encoding="utf-8", errors="replace") + toks = list(tokens(text)) + allowed = rel in ALLOWLIST + for name, args, pos, idx in calls(toks): + line = text.count("\n", 0, pos) + 1 + where = "%s:%d" % (rel, line) + if name in EXTRACT: + roles = EXTRACT[name] + vals = {} + for role, arg in zip(roles, args): + if role: + vals[role] = literal(arg) + if vals.get("id") is None: + continue # a non-literal argument: marked where its literal lives + if not allowed: + problems.append("%s: %s with a literal in a file outside the allowlist" % (where, name)) + continue + key = (vals.get("ctx") or "", vals["id"]) + e = entries.setdefault(key, {"plural": None, "refs": set()}) + e["refs"].add(rel) + if "plural" in vals and vals["plural"] is not None: + e["plural"] = vals["plural"] + try: + a = signature(vals["id"]) + if vals.get("plural") is not None and signature(vals["plural"]) != a: + problems.append("%s: a plural pair whose msgids differ in their conversions" % where) + except ValueError as err: + problems.append("%s: msgid %r refused by the conversion grammar: %s" % (where, vals["id"], err)) + elif name.split("::")[-1] in widgets and name.startswith("ImGui::") and args: + first = args[0] + if first and first[0][0] == "ident" and first[0][1] in T_FAMILY: + problems.append("%s: %s takes a translated label without l10n::Label" % (where, name)) + elif name in ("l10n::Fmt", "l10n::FmtSpan") and args: + fmt_index = 2 if name == "l10n::Fmt" else 3 + if len(args) <= fmt_index: + continue + f = args[fmt_index] + if not f or f[0][0] != "ident" or f[0][1] not in T_FAMILY: + continue + inner = list(calls(f)) + if not inner: + continue + iname, iargs, _, _ = inner[0] + roles = EXTRACT.get(iname, ()) + msgid = None + for role, arg in zip(roles, iargs): + if role == "id": + msgid = literal(arg) + if msgid is None: + continue + try: + want = len(conversions(msgid)) if "%" in msgid else 0 + except ValueError: + continue # reported where the msgid is extracted + have = len(args) - fmt_index - 1 + if have != want: + problems.append("%s: %s passes %d argument(s) for %r, which names %d" % (where, name, have, msgid, want)) + return entries, problems + + +def quote(s): + s = s.replace("\\", "\\\\").replace('"', '\\"').replace("\t", "\\t").replace("\r", "\\r") + parts = s.split("\n") + if len(parts) == 1: + return '"%s"' % s + lines = [p + "\\n" for p in parts[:-1]] + ([parts[-1]] if parts[-1] else []) + return '""\n' + "\n".join('"%s"' % p for p in lines) + + +def render(entries): + out = [HEADER.rstrip("\n")] + for (ctx, msgid) in sorted(entries): + e = entries[(ctx, msgid)] + out.append("") + out.append("#: " + " ".join(sorted(e["refs"]))) + if "%" in msgid: + out.append("#, c-format") + if ctx: + out.append("msgctxt " + quote(ctx)) + out.append("msgid " + quote(msgid)) + if e["plural"] is not None: + out.append("msgid_plural " + quote(e["plural"])) + out.append('msgstr[0] ""') + out.append('msgstr[1] ""') + else: + out.append('msgstr ""') + return "\n".join(out) + "\n" + + +def pack_msgids(path): + """The (ctx, msgid) pairs a .po file defines, by a minimal scan; validating translations is the runtime's.""" + text = path.read_text(encoding="utf-8", errors="replace") + pairs, ctx, cur, field = set(), "", None, None + def unq(s): + return bytes(s[1:-1], "utf-8").decode("unicode_escape").encode("latin-1").decode("utf-8") + for raw in text.splitlines(): + line = raw.strip() + if not line or line.startswith("#"): + continue + if line.startswith("msgctxt "): + ctx, field = unq(line[8:]), "ctx" + elif line.startswith("msgid "): + cur, field = unq(line[6:]), "id" + elif line.startswith('"'): + if field == "id": + cur += unq(line) + elif field == "ctx": + ctx += unq(line) + elif line.startswith("msgstr") or line.startswith("msgid_plural"): + if cur is not None and field == "id" and cur != "": + pairs.add((ctx, cur)) + if line.startswith("msgstr"): + ctx = "" + field = None + return pairs + + +def main(): + entries, problems = scan() + text = render(entries) + if "--check" in sys.argv[1:]: + have = TEMPLATE.read_text(encoding="utf-8").replace("\r\n", "\n") if TEMPLATE.exists() else "" + if have != text: + problems.append("%s is stale -- run .github/ci/l10n_pot.py" % TEMPLATE.relative_to(ROOT).as_posix()) + known = set(entries) + for po in sorted((MOD / "locale").glob("*.po")): + for pair in sorted(pack_msgids(po) - known): + problems.append("%s holds %r, which the template does not" % (po.relative_to(ROOT).as_posix(), pair[1])) + for p in problems: + print("l10n_pot: " + p) + print("l10n_pot: %s (%d entries)" % ("FAIL" if problems else "PASS", len(entries))) + return 1 if problems else 0 + if problems: + for p in problems: + print("l10n_pot: " + p) + print("l10n_pot: not written -- fix the problems above") + return 1 + TEMPLATE.parent.mkdir(parents=True, exist_ok=True) + TEMPLATE.write_text(text, encoding="utf-8", newline="\n") + print("l10n_pot: wrote %s (%d entries)" % (TEMPLATE.relative_to(ROOT).as_posix(), len(entries))) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/workflows/repo-gates.yml b/.github/workflows/repo-gates.yml index 93bdb35bb..4989ac0aa 100644 --- a/.github/workflows/repo-gates.yml +++ b/.github/workflows/repo-gates.yml @@ -147,3 +147,6 @@ jobs: - name: Plural vectors gate (the l10n selftest's expected forms are the independent evaluator's) run: python3 .github/ci/l10n_vectors.py --check + + - name: Translation template gate (the template is current; only allowlisted files extract; call sites follow the rules) + run: python3 .github/ci/l10n_pot.py --check diff --git a/docs/code-map.md b/docs/code-map.md index ee47c9125..6d72bc7ec 100644 --- a/docs/code-map.md +++ b/docs/code-map.md @@ -59,8 +59,9 @@ One folder, one concept, named after it. `src/votv-coop/src/` holds the implemen `po_reader` (a gettext `.po` file read, the only parser of a translator's text), `plural_expr` (a catalogue's plural rule, compiled once), `printf_check` (the conversions a message may carry), `fmt` (the positional formatter), `catalog` and `locale_choice` (private to the folder), `l10n_selftest`. The packs the mod ships -are `src/votv-coop/locale/.po`, embedded by name; `.github/ci/l10n_vectors.py` writes the selftest's -plural vectors. +are `src/votv-coop/locale/.po`, embedded by name, beside the template `multivoid.pot` that +`.github/ci/l10n_pot.py` extracts and gates (the files that may hold player text are its allowlist); +`.github/ci/l10n_vectors.py` writes the selftest's plural vectors. ## `harness/` — boot glue and scripted tests diff --git a/src/votv-coop/locale/multivoid.pot b/src/votv-coop/locale/multivoid.pot new file mode 100644 index 000000000..2e63ae8af --- /dev/null +++ b/src/votv-coop/locale/multivoid.pot @@ -0,0 +1,1876 @@ +# The Multivoid interface's translation template. Generated by .github/ci/l10n_pot.py; never edited by hand. +# A translation is a copy of this file named .po (zh_CN.po), with msgstr filled in. +msgid "" +msgstr "" +"Project-Id-Version: Multivoid\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=INTEGER; plural=EXPRESSION;\n" + +#: src/ui/voice_panel.cpp +#, c-format +msgid "%.0f cm" +msgstr "" + +#: src/ui/loading_screen.cpp +#, c-format +msgid "%.1f / %.1f MB" +msgstr "" + +#: src/ui/bug_report_pane.cpp +#, c-format +msgid "%1$s (%2$llu KB)" +msgstr "" + +#: src/ui/bug_report_pane.cpp +#, c-format +msgid "%1$s (last %2$llu KB of %3$llu KB)" +msgstr "" + +#: src/ui/server_browser_panels.cpp +#, c-format +msgid "%1$s -- UPDATE AVAILABLE: %2$s" +msgstr "" + +#: src/ui/bug_report_pane.cpp +#, c-format +msgid "%1$s -- left out: %2$s" +msgstr "" + +#: src/ui/config_review_panel.cpp +#, c-format +msgid "%1$s = '%2$s' (%3$s) -- %4$s; not in use, %5$s overrides it." +msgstr "" + +#: src/ui/config_review_panel.cpp +#, c-format +msgid "%1$s = '%2$s' (%3$s) -- %4$s; nothing it governs starts until it is fixed." +msgstr "" + +#: src/ui/config_review_panel.cpp +#, c-format +msgid "%1$s = '%2$s' (%3$s) -- %4$s; the built-in default is used." +msgstr "" + +#: src/ui/config_review_panel.cpp +#, c-format +msgid "%1$s = '%2$s' -- not a known setting (typo, or a retired key)." +msgstr "" + +#: src/ui/config_review_panel.cpp +#, c-format +msgid "%1$s appears %2$llu time with different values; the FIRST line (line %3$d) wins. Pick which one to keep:" +msgid_plural "%1$s appears %2$llu times with different values; the FIRST line (line %3$d) wins. Pick which one to keep:" +msgstr[0] "" +msgstr[1] "" + +#: src/coop/comms/peer_action_feed.cpp +#, c-format +msgid "%1$s caught signal '%2$s'" +msgstr "" + +#: src/coop/session/player_handshake_prefs.cpp +#, c-format +msgid "%1$s changed skin to %2$s" +msgstr "" + +#: src/coop/comms/peer_action_feed.cpp +#, c-format +msgid "%1$s could not order: an item was not in the host's store" +msgstr "" + +#: src/coop/comms/peer_action_feed.cpp +#, c-format +msgid "%1$s could not order: the delivery could not be placed" +msgstr "" + +#: src/coop/comms/peer_action_feed.cpp +#, c-format +msgid "%1$s could not order: the host could not read its store" +msgstr "" + +#: src/coop/comms/peer_action_feed.cpp +#, c-format +msgid "%1$s could not order: the host refused it" +msgstr "" + +#: src/coop/comms/peer_action_feed.cpp +#, c-format +msgid "%1$s could not order: there were not enough credits" +msgstr "" + +#: src/coop/comms/peer_action_feed.cpp +#, c-format +msgid "%1$s could not order: too many items in one order" +msgstr "" + +#: src/coop/comms/peer_action_feed.cpp +#, c-format +msgid "%1$s could not sell that" +msgstr "" + +#: src/coop/comms/peer_action_feed.cpp +#, c-format +msgid "%1$s could not sell that: it was already sold" +msgstr "" + +#: src/coop/comms/peer_action_feed.cpp +#, c-format +msgid "%1$s could not sell that: no coin gun exists in the host's world" +msgstr "" + +#: src/coop/comms/peer_action_feed.cpp +#, c-format +msgid "%1$s could not sell that: the host does not have it" +msgstr "" + +#: src/coop/comms/peer_action_feed.cpp +#, c-format +msgid "%1$s could not sell that: the host does not see you next to it" +msgstr "" + +#: src/coop/comms/peer_action_feed.cpp +#, c-format +msgid "%1$s could not sell that: the host hit an internal error" +msgstr "" + +#: src/coop/comms/peer_action_feed.cpp +#, c-format +msgid "%1$s could not sell that: the host's store will not take it" +msgstr "" + +#: src/coop/comms/peer_action_feed.cpp +#, c-format +msgid "%1$s deleted an email: %2$s" +msgstr "" + +#: src/coop/session/player_handshake.cpp +#, c-format +msgid "%1$s is connecting to the game..." +msgstr "" + +#: src/coop/comms/peer_action_feed.cpp +#, c-format +msgid "%1$s is using %2$s" +msgstr "" + +#: src/coop/session/player_handshake.cpp +#, c-format +msgid "%1$s joined the game" +msgstr "" + +#: src/coop/dispatch/event_feed.cpp +#, c-format +msgid "%1$s left the game" +msgstr "" + +#: src/coop/comms/peer_action_feed.cpp +#, c-format +msgid "%1$s sold it for %2$lld point (the host's price)" +msgid_plural "%1$s sold it for %2$lld points (the host's price)" +msgstr[0] "" +msgstr[1] "" + +#: src/coop/session/player_handshake_version.cpp +#, c-format +msgid "%1$s was turned away: Game version mismatch: server plays VOTV %2$s, client has VOTV %3$s. [%4$s]" +msgstr "" + +#: src/coop/session/player_handshake_version.cpp +#, c-format +msgid "%1$s was turned away: malformed join (version field missing) [%2$s]" +msgstr "" + +#: src/coop/session/player_handshake_version.cpp +#, c-format +msgid "%1$s was turned away: official build %2$s [%3$s]" +msgstr "" + +#: src/coop/session/player_handshake_version.cpp +#, c-format +msgid "%1$s was turned away: unofficial build %2$s [%3$s]" +msgstr "" + +#: src/coop/player/sleep_sync.cpp +#, c-format +msgid "%1$u/%2$u players sleeping" +msgstr "" + +#: src/ui/server_browser_panels.cpp +#, c-format +msgid "%d LAN" +msgstr "" + +#: src/ui/server_browser_panels.cpp +#, c-format +msgid "%d direct" +msgstr "" + +#: src/ui/scoreboard.cpp +#, c-format +msgid "%d online" +msgstr "" + +#: src/ui/server_browser_panels.cpp +#, c-format +msgid "%d relayed" +msgstr "" + +#: src/ui/world_rules_panel.cpp +#, c-format +msgid "%llu rule in force differs from the rules saved with this world." +msgid_plural "%llu rules in force differ from the rules saved with this world." +msgstr[0] "" +msgstr[1] "" + +#: src/ui/loading_screen.cpp +#, c-format +msgid "%llu s in this step" +msgstr "" + +#: src/ui/server_browser_panels.cpp +#, c-format +msgid "%llu server" +msgid_plural "%llu servers" +msgstr[0] "" +msgstr[1] "" + +#: src/ui/bug_report_pane.cpp +#, c-format +msgid "%s (without passwords)" +msgstr "" + +#: src/ui/loading_screen.cpp +#, c-format +msgid "%u / %u objects" +msgstr "" + +#: src/ui/scoreboard.cpp +msgid "(hidden -- friends join by invite/IP)" +msgstr "" + +#: src/ui/server_browser_panels.cpp +msgid "(locked)" +msgstr "" + +#: src/ui/world_rules_panel.cpp +msgid "(no description)" +msgstr "" + +#: src/ui/admin_panel.cpp +msgid "(no nick)" +msgstr "" + +#: src/ui/skins_panel.cpp +msgid "(no preview)" +msgstr "" + +#: src/ui/scoreboard.cpp +msgid "(others can find your game)" +msgstr "" + +#: src/ui/voice_panel.cpp +msgid "(system default)" +msgstr "" + +#: src/ui/server_browser_panels.cpp +msgid "(unnamed)" +msgstr "" + +#: src/ui/scoreboard.cpp +msgid "(you)" +msgstr "" + +#: src/ui/server_browser_rows.cpp +msgid "(your server)" +msgstr "" + +#: src/ui/net_stats_panel.cpp +msgid "" +"A compact live panel in the top-right corner: receive/send rate right\n" +"now (wire-level, includes protocol overhead), total downloaded/uploaded\n" +"this session, packets/s, peers + ping, and a 60 s rate graph.\n" +"Works for host and clients. Saved to multivoid.ini (ui.netstats)." +msgstr "" + +#: src/ui/loading_screen.cpp +msgid "A new game can take a moment to load" +msgstr "" + +#: src/coop/net/end_reason.cpp +msgid "A newer connection with your identity replaced this one." +msgstr "" + +#: src/ui/skins_panel.cpp +msgid "A pak that carries no skin (another mod's) is dropped from this list once the game has been asked -- it is not a skin." +msgstr "" + +#: src/coop/session/player_handshake_version.cpp +msgid "A player" +msgstr "" + +#: src/coop/net/end_reason.cpp +msgid "A proof arrived before a hello." +msgstr "" + +#: src/coop/net/end_reason.cpp +msgid "A second hello arrived on one connection." +msgstr "" + +#: src/ui/admin_panel.cpp +msgid "Actions" +msgstr "" + +#: src/ui/voice_panel.cpp +msgid "Activation" +msgstr "" + +#: src/ui/dev_menu.cpp +msgid "Administration" +msgstr "" + +#: include/coop/config/config_registry_rows.inc +msgid "Allow other builds" +msgstr "" + +#: src/ui/admin_panel.cpp src/ui/scoreboard.cpp +msgid "Also refuse their address" +msgstr "" + +#: src/ui/admin_panel.cpp src/ui/scoreboard.cpp +msgid "Also refuses anyone connecting from the same address -- people who share their router or their provider's address are refused too. It only works on a direct connection: a player who comes through a relay is refused by their identity alone." +msgstr "" + +#: src/ui/dev_menu.cpp +msgid "As a client you see only what the host allows you." +msgstr "" + +#: src/ui/scoreboard.cpp +msgid "Ban (permanent)" +msgstr "" + +#: src/ui/admin_panel.cpp +msgid "Ban player" +msgstr "" + +#: src/ui/admin_panel.cpp +msgid "Banned" +msgstr "" + +#: src/ui/config_review_panel.cpp +msgid "Body skin not saved" +msgstr "" + +#: src/ui/end_reason_dialog.cpp +msgid "COULD NOT CONNECT" +msgstr "" + +#: src/ui/server_browser_panels.cpp +#, c-format +msgid "Cannot reach the %s server list. Check your connection." +msgstr "" + +#: src/ui/server_browser_panels.cpp +#, c-format +msgid "Cannot reach the %s server list. Try another list above, or check your connection." +msgstr "" + +#: src/ui/server_browser_actions.cpp +msgid "Change name" +msgstr "" + +#: src/ui/dev_menu.cpp src/ui/fonts.cpp +msgid "Chat" +msgstr "" + +#: src/ui/world_rules_panel.cpp +msgid "Click on the name of the rule to see its description." +msgstr "" + +#: src/ui/scoreboard.cpp +msgid "Click to mute your mic" +msgstr "" + +#: src/ui/end_reason_dialog.cpp +#, c-format +msgid "Code %s -- include it in a bug report" +msgstr "" + +#: src/ui/dev_menu.cpp +msgid "" +"Colors your nick everywhere it shows -- nameplate, chat, player list --\n" +"on every peer's screen. Synced live and to late joiners; persists." +msgstr "" + +#: src/ui/scoreboard.cpp +msgid "Confirm ban" +msgstr "" + +#: src/coop/session/player_handshake.cpp +#, c-format +msgid "Connecting to %1$s's game..." +msgstr "" + +#: src/ui/loading_screen.cpp +#, c-format +msgid "Connecting to %s" +msgstr "" + +#: src/ui/server_browser_panels.cpp +#, c-format +msgid "Connection: %s" +msgstr "" + +#: src/ui/console.cpp +msgid "Console" +msgstr "" + +#: src/ui/bug_report_pane.cpp +msgid "Contact (optional, e.g. a Discord name)" +msgstr "" + +#: src/ui/dev_menu.cpp +msgid "Cosmetics" +msgstr "" + +#: src/coop/net/end_reason.cpp +msgid "Could not compute the password proof on this machine." +msgstr "" + +#: src/ui/browser_input_screens.cpp +msgid "Could not connect to that address -- check it, or another action is already in flight." +msgstr "" + +#: src/coop/bug_report/report_bundle.cpp +msgid "Could not find the log." +msgstr "" + +#: src/coop/net/end_reason.cpp +msgid "Could not reach the server list." +msgstr "" + +#: src/coop/bug_report/report_bundle.cpp +msgid "Could not read the log." +msgstr "" + +#: src/ui/config_review_panel.cpp +msgid "Could not rewrite multivoid.ini (it is missing, or the write failed) -- nothing was changed." +msgstr "" + +#: src/ui/config_review_panel.cpp +msgid "Could not rewrite multivoid.ini (locked or failing) -- nothing was changed. Close whatever holds the file and press Tidy up again." +msgstr "" + +#: src/ui/config_review_panel.cpp +msgid "Could not rewrite multivoid.ini: it holds a zero byte or a UTF-16 byte-order mark -- nothing was changed. Save it again as UTF-8 and press Tidy up again." +msgstr "" + +#: src/coop/commands/command_sync.cpp +msgid "Could not send the command." +msgstr "" + +#: src/coop/net/end_reason.cpp +msgid "Could not send the identity proof to the host." +msgstr "" + +#: src/ui/browser_input_screens.cpp src/ui/server_browser_actions.cpp +msgid "Could not start that connection -- another action is already in flight." +msgstr "" + +#: src/coop/net/end_reason.cpp +msgid "Could not start the connection." +msgstr "" + +#: src/coop/net/end_reason.cpp +msgid "Could not start the identity exchange with this host." +msgstr "" + +#: src/coop/bug_report/report_bundle.cpp +msgid "Could not start the report." +msgstr "" + +#: src/coop/bug_report/report_bundle.cpp +msgid "Could not write the report file." +msgstr "" + +#: src/ui/dev_menu.cpp +msgid "Custom nickname color" +msgstr "" + +#: src/ui/link_format.cpp +msgid "DIRECT" +msgstr "" + +#: src/ui/end_reason_dialog.cpp +msgid "DISCONNECTED" +msgstr "" + +#: src/ui/dev_menu.cpp +msgid "Developer tools are hidden. Set [dev] devkeys=1 in multivoid.ini to show them." +msgstr "" + +#: src/ui/voice_panel.cpp +msgid "" +"Device changes apply immediately (the voice engine\n" +"reopens on the game's next tick)." +msgstr "" + +#: src/ui/voice_panel.cpp +msgid "Devices" +msgstr "" + +#: src/ui/link_format.cpp +msgid "Direct" +msgstr "" + +#: src/ui/server_browser_actions.cpp +msgid "Direct connect" +msgstr "" + +#: src/ui/admin_panel.cpp src/ui/scoreboard.cpp +msgid "Disconnected now and refused whenever they rejoin." +msgstr "" + +#: src/ui/net_stats_panel.cpp +#, c-format +msgid "Down: %1$s now, %2$s total, %3$llu packet" +msgid_plural "Down: %1$s now, %2$s total, %3$llu packets" +msgstr[0] "" +msgstr[1] "" + +#: src/ui/loading_screen.cpp +msgid "Downloading the host's world" +msgstr "" + +#: src/ui/skins_panel.cpp +msgid "" +"Dr. Kel\n" +"(default)" +msgstr "" + +#: src/ui/config_review_panel.cpp +msgid "Duplicate settings with different values" +msgstr "" + +#: src/ui/dev_menu.cpp +msgid "Each surface picks its own family; applies on the next frame." +msgstr "" + +#: src/ui/admin_panel.cpp +msgid "Everyone in the registry is online." +msgstr "" + +#: src/coop/player/sleep_sync.cpp +msgid "Everyone is asleep -- the night passes..." +msgstr "" + +#: src/ui/dev_menu.cpp +msgid "Everything scales with the screen automatically; this is your extra zoom." +msgstr "" + +#: src/ui/loading_screen.cpp +msgid "Exchanging identities with the host" +msgstr "" + +#: src/ui/bug_report_pane.cpp +msgid "Files that will be included" +msgstr "" + +#: src/ui/loading_screen.cpp +#, c-format +msgid "Finding a route to %s" +msgstr "" + +#: src/ui/world_rules_panel.cpp +msgid "Gamemode:" +msgstr "" + +#: src/coop/player/sleep_sync.cpp +msgid "Good morning -- everyone is rested." +msgstr "" + +#: src/ui/dev_menu.cpp +#, c-format +msgid "Graphics API: %s" +msgstr "" + +#: src/ui/scoreboard.cpp +msgid "HOST" +msgstr "" + +#: src/ui/voice_panel.cpp +#, c-format +msgid "" +"Hold the key to talk. Change the key via %s\n" +"in multivoid.ini (single letter or a virtual-key number)." +msgstr "" + +#: src/ui/admin_panel.cpp +msgid "" +"Host administration. Bans follow the player and survive restarts;\n" +"the registry remembers every player who ever joined this host." +msgstr "" + +#: src/ui/server_browser_actions.cpp +msgid "Host game" +msgstr "" + +#: src/ui/admin_panel.cpp src/ui/scoreboard.cpp +msgid "ID" +msgstr "" + +#: src/ui/dev_menu.cpp +msgid "Interface" +msgstr "" + +#: src/coop/session/player_handshake.cpp +#, c-format +msgid "Joined %1$s's game" +msgstr "" + +#: src/ui/loading_screen.cpp +#, c-format +msgid "Joining %s" +msgstr "" + +#: src/ui/config_review_panel.cpp +#, c-format +msgid "Keep line %d: %s" +msgstr "" + +#: src/ui/link_format.cpp +msgid "LAN" +msgstr "" + +#: src/ui/admin_panel.cpp +msgid "Last seen" +msgstr "" + +#: src/ui/server_browser_panels.cpp +#, c-format +msgid "Last seen: %ds ago" +msgstr "" + +#: src/coop/net/end_reason.cpp +msgid "Left the session." +msgstr "" + +#: include/coop/config/config_registry_rows.inc +msgid "Let players whose Multivoid build differs from this server's join -- an unofficial build when this server runs the official one, the official build when this server runs a modified one. Applies to the next joins; players already in stay." +msgstr "" + +#: src/ui/voice_panel.cpp +msgid "Levels" +msgstr "" + +#: src/ui/admin_panel.cpp src/ui/scoreboard.cpp +msgid "Link" +msgstr "" + +#: src/ui/loading_screen.cpp +msgid "Loading the world" +msgstr "" + +#: src/ui/loading_screen.cpp +#, c-format +msgid "Looking up %s" +msgstr "" + +#: src/ui/loading_screen.cpp +msgid "MULTIPLAYER" +msgstr "" + +#: src/ui/config_review_panel.cpp +msgid "MULTIVOID SETTINGS CHECK" +msgstr "" + +#: include/coop/config/config_registry_rows.inc +msgid "Maximum voice audibility distance in centimeters (4800 = 48 m); 0 = unlimited (a whisper stays local); the host's setting in a session. A value of 1..99 is a near-mute range, the host's to choose." +msgstr "" + +#: src/ui/fonts.cpp +msgid "Menu / panels" +msgstr "" + +#: src/ui/scoreboard.cpp src/ui/voice_panel.cpp +msgid "Mic" +msgstr "" + +#: src/ui/voice_panel.cpp +msgid "Mic gain" +msgstr "" + +#: src/ui/voice_icons.cpp +msgid "Mic muted" +msgstr "" + +#: src/ui/voice_panel.cpp +msgid "Microphone" +msgstr "" + +#: src/ui/browser_input_screens.cpp +msgid "Multivoid - Change name" +msgstr "" + +#: src/ui/browser_input_screens.cpp +msgid "Multivoid - Direct connect" +msgstr "" + +#: src/ui/browser_input_screens.cpp +msgid "Multivoid - Password" +msgstr "" + +#: src/ui/server_browser_native.cpp +msgid "Multivoid - Server Browser" +msgstr "" + +#: src/ui/scoreboard.cpp +msgid "Mute for me" +msgstr "" + +#: src/ui/voice_panel.cpp +msgid "Mute my microphone" +msgstr "" + +#: src/ui/scoreboard.cpp +msgid "Muted for you (click for volume)" +msgstr "" + +#: src/ui/net_stats_panel.cpp +#, c-format +msgid "NET %1$llu peer %2$d ms" +msgid_plural "NET %1$llu peers %2$d ms" +msgstr[0] "" +msgstr[1] "" + +#: src/ui/net_stats_panel.cpp +#, c-format +msgid "NET %llu peer" +msgid_plural "NET %llu peers" +msgstr[0] "" +msgstr[1] "" + +#: src/ui/net_stats_panel.cpp +msgid "NET offline" +msgstr "" + +#: src/ui/dev_menu.cpp +msgid "Nameplate" +msgstr "" + +#: src/coop/player/nameplate.cpp +msgid "Nameplate: hidden from other players" +msgstr "" + +#: src/coop/player/nameplate.cpp +msgid "Nameplate: shown to other players" +msgstr "" + +#: src/ui/fonts.cpp +msgid "Nameplates" +msgstr "" + +#: src/ui/fonts.cpp +msgid "Net stats" +msgstr "" + +#: src/ui/dev_menu.cpp +msgid "Network" +msgstr "" + +#: src/ui/dev_menu.cpp +msgid "Nickname color" +msgstr "" + +#: src/coop/player/nick_color.cpp +msgid "Nickname color: applied (synced to other players)" +msgstr "" + +#: src/coop/player/nick_color.cpp +msgid "Nickname color: reset to default" +msgstr "" + +#: src/ui/net_stats_panel.cpp +msgid "No active session." +msgstr "" + +#: src/coop/net/end_reason.cpp +msgid "No answer from the host." +msgstr "" + +#: src/ui/admin_panel.cpp +msgid "No banned players." +msgstr "" + +#: src/ui/voice_panel.cpp +msgid "No microphone -- voice is receive-only." +msgstr "" + +#: src/ui/voice_panel.cpp +msgid "No output device -- you can't hear voice." +msgstr "" + +#: src/ui/scoreboard.cpp +msgid "No players." +msgstr "" + +#: src/ui/admin_panel.cpp +msgid "No remote players connected." +msgstr "" + +#: src/coop/net/end_reason.cpp +msgid "No route to the host through its firewall or router." +msgstr "" + +#: src/ui/dev_menu.cpp +msgid "No tools here yet -- coming soon." +msgstr "" + +#: src/ui/admin_panel.cpp +msgid "Nobody in the registry yet (players are recorded when they join)." +msgstr "" + +#: src/ui/dev_menu.cpp +msgid "" +"Off = your floating name/health bar disappears on every peer's\n" +"screen -- synced live and to late joiners; persists across sessions." +msgstr "" + +#: src/ui/net_stats_panel.cpp +msgid "Off by default; persists across sessions." +msgstr "" + +#: src/ui/admin_panel.cpp +msgid "Offline (seen before)" +msgstr "" + +#: src/ui/admin_panel.cpp +msgid "Online" +msgstr "" + +#: src/ui/world_rules_panel.cpp +msgid "Other" +msgstr "" + +#: src/ui/voice_panel.cpp +msgid "Output" +msgstr "" + +#: src/ui/dev_menu.cpp +msgid "Overlay fonts (per surface):" +msgstr "" + +#: src/ui/scoreboard.cpp +msgid "PLAYERS" +msgstr "" + +#: src/ui/browser_input_screens.cpp +msgid "Password (leave empty if the server has none)" +msgstr "" + +#: src/ui/dev_menu.cpp +msgid "Peer action notifications" +msgstr "" + +#: src/ui/skins_panel.cpp +msgid "Peers WITHOUT that pak see the default kel body instead." +msgstr "" + +#: src/ui/net_stats_panel.cpp +#, c-format +msgid "Peers: %1$d, worst ping %2$d ms" +msgstr "" + +#: src/ui/net_stats_panel.cpp +#, c-format +msgid "Peers: %d" +msgstr "" + +#: src/ui/admin_panel.cpp src/ui/scoreboard.cpp +#, c-format +msgid "Permanently ban %s?" +msgstr "" + +#: src/ui/server_browser_actions.cpp +msgid "Pick a server from the list first." +msgstr "" + +#: src/ui/admin_panel.cpp src/ui/scoreboard.cpp +msgid "Ping" +msgstr "" + +#: src/coop/comms/peer_action_feed.cpp src/ui/admin_panel.cpp src/ui/scoreboard.cpp +msgid "Player" +msgstr "" + +#: src/ui/admin_panel.cpp +#, c-format +msgid "" +"Player id %1$s\n" +"Address %2$s" +msgstr "" + +#: src/ui/admin_panel.cpp src/ui/scoreboard.cpp +#, c-format +msgid "Player id %s" +msgstr "" + +#: src/ui/dev_menu.cpp +msgid "Players" +msgstr "" + +#: src/ui/server_browser_panels.cpp src/ui/server_browser_rows.cpp +#, c-format +msgid "Players: %d/%d" +msgstr "" + +#: src/coop/bug_report/report_form.cpp +msgid "Please describe what happened (at least 20 characters)." +msgstr "" + +#: src/ui/skins_panel.cpp +msgid "Preview tile = .png/.bmp beside the pak." +msgstr "" + +#: src/ui/voice_panel.cpp +#, c-format +msgid "Push-to-talk (key: %s)" +msgstr "" + +#: src/ui/link_format.cpp +msgid "RELAY" +msgstr "" + +#: src/ui/world_rules_panel.cpp +msgid "Read-only. The host's save sets the rules for everyone." +msgstr "" + +#: src/ui/world_rules_panel.cpp +msgid "Reading rules..." +msgstr "" + +#: src/ui/admin_panel.cpp +msgid "Reason" +msgstr "" + +#: src/ui/loading_screen.cpp +msgid "Receiving world from the host" +msgstr "" + +#: src/ui/skins_panel.cpp +msgid "Refresh list" +msgstr "" + +#: src/ui/server_browser_actions.cpp +msgid "Refreshing the server list..." +msgstr "" + +#: src/ui/admin_panel.cpp +msgid "Refused whenever they rejoin." +msgstr "" + +#: src/ui/link_format.cpp +msgid "Relay" +msgstr "" + +#: src/ui/fonts.cpp +msgid "Release toast" +msgstr "" + +#: src/coop/dispatch/event_feed.cpp src/ui/admin_panel.cpp src/ui/scoreboard.cpp +msgid "Remote player" +msgstr "" + +#: src/ui/dev_menu.cpp +msgid "Report" +msgstr "" + +#: src/ui/dev_menu.cpp +msgid "Report a bug" +msgstr "" + +#: src/ui/voice_panel.cpp +msgid "Rescan devices" +msgstr "" + +#: src/ui/config_review_panel.cpp +#, c-format +msgid "" +"Rewrites multivoid.ini in the standard layout: [net] first, [dev]\n" +"last, each setting under its section, exact-duplicate lines merged.\n" +"Unknown settings and invalid values are commented out (kept in the\n" +"file, just disabled) -- that resolves their warnings above. An\n" +"invalid value of a setting that refuses instead (%s) is kept as\n" +"it is: disabling it would switch that setting to its default.\n" +"Conflicting duplicates are NEVER auto-resolved -- use the keep-line\n" +"buttons above. Your comments travel with their settings." +msgstr "" + +#: src/ui/world_rules_panel.cpp +msgid "Rule" +msgstr "" + +#: src/ui/dev_menu.cpp src/ui/world_rules_panel.cpp +msgid "Rules" +msgstr "" + +#: src/ui/bug_report_pane.cpp +msgid "Save report" +msgstr "" + +#: src/ui/dev_menu.cpp +msgid "" +"Saved to multivoid.ini (ui.font.menu/chat/net/nameplate/toast,\n" +"each with its own default). Fixedsys (VOTV) = game terminal pixel\n" +"font; JetBrains/Cascadia monospace; Roboto proportional." +msgstr "" + +#: src/ui/bug_report_pane.cpp +#, c-format +msgid "Saved: %1$s (%2$llu KB)" +msgstr "" + +#: src/ui/bug_report_pane.cpp +msgid "Saves a report file on this PC. Nothing is sent." +msgstr "" + +#: src/ui/bug_report_pane.cpp +msgid "Saving the report..." +msgstr "" + +#: src/ui/dev_menu.cpp +msgid "" +"Scales the WHOLE overlay (menus, chat, nameplates) on top of the\n" +"automatic resolution scale. Applies when you release the slider\n" +"(the fonts re-bake). Saved to multivoid.ini (ui.scale)." +msgstr "" + +#: src/ui/dev_menu.cpp +msgid "Select a category on the left." +msgstr "" + +#: src/ui/server_browser_panels.cpp +msgid "Select a server" +msgstr "" + +#: src/ui/browser_input_screens.cpp +msgid "Server address" +msgstr "" + +#: src/ui/server_browser_panels.cpp +msgid "Server info:" +msgstr "" + +#: src/ui/browser_input_screens.cpp +msgid "Server password" +msgstr "" + +#: src/coop/session/server_settings_sync.cpp +#, c-format +msgid "Server setting changed: %1$s is now %2$s." +msgstr "" + +#: src/ui/dev_menu.cpp +msgid "Server settings" +msgstr "" + +#: src/ui/net_stats_panel.cpp +msgid "Session" +msgstr "" + +#: src/ui/dev_menu.cpp +msgid "" +"Show a chat line when another player does a shared action\n" +"(e.g. deletes an email). Local preference; persists across\n" +"sessions (ui.chat.peer_actions)." +msgstr "" + +#: src/ui/bug_report_pane.cpp +msgid "Show in folder" +msgstr "" + +#: src/ui/scoreboard.cpp +msgid "Show in server browser" +msgstr "" + +#: src/ui/dev_menu.cpp +msgid "Show my nameplate to other players" +msgstr "" + +#: src/ui/net_stats_panel.cpp +msgid "Show network stats overlay" +msgstr "" + +#: src/ui/server_browser_native.cpp +#, c-format +msgid "Showing the %s server list." +msgstr "" + +#: src/coop/player/local_body.cpp +#, c-format +msgid "Skin '%s' is not installed here -- keeping your current one" +msgstr "" + +#: src/coop/player/local_body.cpp +#, c-format +msgid "Skin: %s" +msgstr "" + +#: src/ui/dev_menu.cpp +msgid "Skins" +msgstr "" + +#: src/ui/skins_panel.cpp +msgid "Skins come from converter .pak files under Content/Paks/LogicMods (any subfolder). One pak may carry several skins -- the four scientists ship together in scientists.pak." +msgstr "" + +#: src/coop/player/sleep_sync.cpp +msgid "Sleep interrupted -- everyone wakes up." +msgstr "" + +#: src/coop/net/end_reason.cpp +msgid "Something was sent before the identity was proved." +msgstr "" + +#: src/ui/voice_panel.cpp +msgid "" +"Speech louder than this transmits. Watch the mic\n" +"meter: set the slider just above your room's noise." +msgstr "" + +#: src/ui/loading_screen.cpp +msgid "Starting your server" +msgstr "" + +#: src/ui/loading_screen.cpp +#, c-format +msgid "Starting your server -- loading %s" +msgstr "" + +#: src/ui/dev_menu.cpp +msgid "Stats" +msgstr "" + +#: src/ui/server_settings_pane.cpp +msgid "Takes effect at the next session." +msgstr "" + +#: src/ui/voice_icons.cpp +msgid "Talking" +msgstr "" + +#: src/ui/scoreboard.cpp +msgid "Teleport to me" +msgstr "" + +#: src/coop/commands/command_sync.cpp +msgid "That line is too long." +msgstr "" + +#: src/coop/bug_report/report_form.cpp +msgid "That text is too long." +msgstr "" + +#: src/ui/server_browser_actions.cpp +msgid "That's your own server -- you're already hosting it." +msgstr "" + +#: src/ui/admin_panel.cpp +msgid "The ban file has entries this version cannot read, so bans made now last until the server stops. See the log." +msgstr "" + +#: src/coop/net/end_reason.cpp +msgid "The connection fell too far behind and was closed." +msgstr "" + +#: src/coop/net/end_reason.cpp +msgid "The connection was lost." +msgstr "" + +#: src/coop/net/end_reason.cpp +msgid "The download of the host's world stopped part way through." +msgstr "" + +#: src/coop/bug_report/report_bundle.cpp +msgid "The game is closing." +msgstr "" + +#: src/coop/net/end_reason.cpp +msgid "The game is shutting down." +msgstr "" + +#: src/coop/net/end_reason.cpp +msgid "The host banned you." +msgstr "" + +#: src/coop/net/end_reason.cpp +msgid "The host closed the connection." +msgstr "" + +#: src/coop/net/end_reason.cpp +msgid "The host closed the connection: it fell too far behind." +msgstr "" + +#: src/coop/net/end_reason.cpp +msgid "The host could not accept the connection." +msgstr "" + +#: src/coop/net/end_reason.cpp +msgid "The host could not check the password." +msgstr "" + +#: src/coop/net/end_reason.cpp +msgid "The host could not prove its identity." +msgstr "" + +#: src/coop/net/end_reason.cpp +msgid "The host could not read your hello." +msgstr "" + +#: src/coop/net/end_reason.cpp +msgid "The host could not read your proof." +msgstr "" + +#: src/coop/net/end_reason.cpp +msgid "The host could not send its challenge." +msgstr "" + +#: src/coop/net/end_reason.cpp +msgid "The host did not answer through the signaling server. It may be offline, or reconnecting -- try again in a minute." +msgstr "" + +#: src/coop/net/end_reason.cpp +msgid "The host ended the session." +msgstr "" + +#: src/coop/net/end_reason.cpp +msgid "The host has no randomness to challenge with." +msgstr "" + +#: src/ui/loading_screen.cpp +msgid "The host is settling its world" +msgstr "" + +#: src/coop/net/end_reason.cpp +msgid "The host kicked you." +msgstr "" + +#: src/coop/net/end_reason.cpp +msgid "The host never finished preparing its world for you." +msgstr "" + +#: src/coop/net/end_reason.cpp +msgid "The host never sent you the world it owed." +msgstr "" + +#: src/coop/net/end_reason.cpp +msgid "The host never sent your inventory." +msgstr "" + +#: src/coop/net/end_reason.cpp +msgid "The host plays a different version of the game." +msgstr "" + +#: src/coop/net/end_reason.cpp +msgid "The host received a malformed packet." +msgstr "" + +#: src/coop/net/end_reason.cpp +msgid "The host runs a newer build of the mod. Update to join." +msgstr "" + +#: src/coop/net/end_reason.cpp +msgid "The host runs an older build of the mod. They need to update." +msgstr "" + +#: src/coop/net/end_reason.cpp +msgid "The host sent an identity challenge this build cannot read." +msgstr "" + +#: src/coop/net/end_reason.cpp +msgid "The host was busy, and this connection was the slowest to prove itself." +msgstr "" + +#: src/coop/net/end_reason.cpp +msgid "The host's world arrived damaged and could not be used." +msgstr "" + +#: src/coop/net/end_reason.cpp +msgid "The host's world would not load on this machine." +msgstr "" + +#: src/coop/net/end_reason.cpp +msgid "The identity proof did not arrive in time." +msgstr "" + +#: src/coop/net/end_reason.cpp +msgid "The join failed before it started. The log has the error." +msgstr "" + +#: src/coop/bug_report/report_bundle.cpp +msgid "The report could not be made." +msgstr "" + +#: src/coop/net/end_reason.cpp +msgid "The server is full." +msgstr "" + +#: src/coop/net/end_reason.cpp +msgid "The server list gave a bad address for this host." +msgstr "" + +#: src/coop/net/end_reason.cpp +msgid "The signaling server could not reach the host." +msgstr "" + +#: src/coop/net/end_reason.cpp +msgid "The transport handshake with the host failed." +msgstr "" + +#: src/ui/admin_panel.cpp src/ui/scoreboard.cpp +msgid "Their identity is not proved yet." +msgstr "" + +#: src/ui/config_review_panel.cpp +msgid "This key holds your body skin -- if the winning value is not the one you expect, keep the other line." +msgstr "" + +#: src/ui/config_review_panel.cpp +#, c-format +msgid "This launch runs on environment overrides and built-in defaults; the settings that refuse instead of falling back (%s) refuse what they govern until the file can be read." +msgstr "" + +#: src/coop/net/end_reason.cpp +msgid "This machine has no working connection to the signaling server, so the host could not be dialled." +msgstr "" + +#: src/ui/world_rules_panel.cpp +msgid "" +"This rule is achievement-locked!\n" +"Unlock this rule by getting following achievements:" +msgstr "" + +#: src/ui/world_rules_panel.cpp +#, c-format +msgid "" +"This rule is day-locked!\n" +"Unlock this rule by playing past day %d" +msgstr "" + +#: src/coop/net/end_reason.cpp +msgid "This server admits only the official Multivoid build. Install the official release (the whole zip)." +msgstr "" + +#: src/ui/server_settings_pane.cpp +msgid "This server has no settings to change here." +msgstr "" + +#: src/coop/net/end_reason.cpp +msgid "This server needs a password, and nothing told this machine which host it was dialling." +msgstr "" + +#: src/coop/net/end_reason.cpp +msgid "This server needs a password." +msgstr "" + +#: src/coop/net/end_reason.cpp +msgid "This server runs a modified (unofficial) Multivoid build and admits only modified builds." +msgstr "" + +#: src/ui/voice_panel.cpp +msgid "Threshold" +msgstr "" + +#: src/ui/voice_panel.cpp +msgid "Threshold near 0 dB -- the mic will almost never trigger. Try -50 dB." +msgstr "" + +#: src/ui/config_review_panel.cpp +#, c-format +msgid "Tidied: %d duplicate line(s) merged, %d setting(s) placed under their sections, %d unknown/invalid line(s) commented out. Anything still listed above needs a choice or can't be fixed from the file." +msgstr "" + +#: src/ui/config_review_panel.cpp +msgid "Tidy up multivoid.ini" +msgstr "" + +#: src/coop/net/end_reason.cpp +msgid "Too many connections from you in a short time. Try again shortly." +msgstr "" + +#: src/coop/net/end_reason.cpp +msgid "Too many password attempts. Try again in ten minutes." +msgstr "" + +#: include/coop/commands/command_dispatcher.h +msgid "Type /help for the commands." +msgstr "" + +#: src/ui/browser_input_screens.cpp +msgid "Type a name first." +msgstr "" + +#: src/ui/browser_input_screens.cpp +msgid "Type an address first -- host or host:port." +msgstr "" + +#: src/ui/browser_input_screens.cpp +msgid "Type the password this server was locked with." +msgstr "" + +#: src/ui/dev_menu.cpp +msgid "UI size" +msgstr "" + +#: src/ui/config_review_panel.cpp +msgid "Unknown settings (nothing reads these)" +msgstr "" + +#: src/ui/net_stats_panel.cpp +#, c-format +msgid "Up: %1$s now, %2$s total, %3$llu packet" +msgid_plural "Up: %1$s now, %2$s total, %3$llu packets" +msgstr[0] "" +msgstr[1] "" + +#: src/ui/server_browser_actions.cpp +msgid "Update list" +msgstr "" + +#: src/ui/scoreboard.cpp +msgid "V: voice settings" +msgstr "" + +#: src/ui/world_rules_panel.cpp +msgid "Value" +msgstr "" + +#: src/ui/config_review_panel.cpp +msgid "Values that could not be used" +msgstr "" + +#: src/ui/server_browser_panels.cpp +#, c-format +msgid "Version: %1$s%2$s" +msgstr "" + +#: src/ui/server_browser_panels.cpp +#, c-format +msgid "Version: %1$s%2$s -- built for a different game version" +msgstr "" + +#: src/ui/server_browser_panels.cpp +#, c-format +msgid "Version: %1$s%2$s -- the host must update" +msgstr "" + +#: src/ui/server_browser_panels.cpp +#, c-format +msgid "Version: %1$s%2$s -- you must update" +msgstr "" + +#: src/ui/voice_panel.cpp +msgid "Voice activation" +msgstr "" + +#: src/ui/voice_panel.cpp +msgid "Voice chat" +msgstr "" + +#: src/ui/voice_panel.cpp +#, c-format +msgid "Voice chat is turned off (%s=0 in multivoid.ini). Set it to 1 and restart the game to use voice." +msgstr "" + +#: src/ui/scoreboard.cpp +msgid "Voice connected (click for volume)" +msgstr "" + +#: src/ui/voice_icons.cpp +msgid "Voice disconnected" +msgstr "" + +#: src/ui/voice_icons.cpp +msgid "Voice off" +msgstr "" + +#: include/coop/config/config_registry_rows.inc +msgid "Voice range" +msgstr "" + +#: src/ui/voice_panel.cpp +#, c-format +msgid "Voice range: %s" +msgstr "" + +#: src/ui/voice_panel.cpp +#, c-format +msgid "Voice range: %s (set by the host)" +msgstr "" + +#: src/ui/voice_panel.cpp +#, c-format +msgid "Voice range: %s (your server's; multivoid.ini until the settings page)" +msgstr "" + +#: src/ui/voice_panel.cpp +msgid "Voice volume" +msgstr "" + +#: src/ui/server_browser_panels.cpp +#, c-format +msgid "Waiting for the %s list..." +msgstr "" + +#: src/ui/loading_screen.cpp +msgid "Waiting for the host to capture its world" +msgstr "" + +#: src/ui/loading_screen.cpp +msgid "Waiting for the host's world" +msgstr "" + +#: src/ui/bug_report_pane.cpp +msgid "What happened" +msgstr "" + +#: src/ui/bug_report_pane.cpp +msgid "What you expected" +msgstr "" + +#: src/ui/admin_panel.cpp +msgid "When" +msgstr "" + +#: src/ui/voice_icons.cpp +msgid "Whispering" +msgstr "" + +#: src/ui/dev_menu.cpp +msgid "World" +msgstr "" + +#: src/ui/world_rules_panel.cpp +msgid "World rules unavailable (still loading?)." +msgstr "" + +#: src/ui/server_browser_panels.cpp +#, c-format +msgid "World: %s" +msgstr "" + +#: src/coop/net/end_reason.cpp +msgid "Wrong password." +msgstr "" + +#: src/coop/net/end_reason.cpp +msgid "You are banned from this server." +msgstr "" + +#: src/ui/config_review_panel.cpp +#, c-format +msgid "Your body skin (%s) could not be saved to multivoid.ini this launch -- it is temporary for this session. Fix whatever keeps the file from being read or saved (a lock from an editor or antivirus, permissions, or a file saved as UTF-16); nothing is lost once a launch can save again." +msgstr "" + +#: src/ui/skins_panel.cpp +msgid "Your body skin -- what YOU see looking down and what OTHERS see. Saved to multivoid.ini; restored on rejoin." +msgstr "" + +#: src/coop/net/end_reason.cpp +msgid "Your connection presented no key identity." +msgstr "" + +#: src/coop/net/end_reason.cpp +msgid "Your game loaded the world but never finished settling into it." +msgstr "" + +#: src/coop/net/end_reason.cpp +msgid "Your identity changed during the exchange." +msgstr "" + +#: src/coop/net/end_reason.cpp +msgid "Your identity proof did not verify." +msgstr "" + +#: src/ui/scoreboard.cpp +msgid "Your mic is muted -- click to unmute" +msgstr "" + +#: src/coop/net/end_reason.cpp +msgid "Your mod build differs from the host's." +msgstr "" + +#: src/ui/browser_input_screens.cpp +msgid "Your name" +msgstr "" + +#: src/ui/server_browser_panels.cpp +#, c-format +msgid "Your name: %s" +msgstr "" + +#: src/coop/net/end_reason.cpp +msgid "Your net.ice setting could not be read as all or relay. Set it to one of them in multivoid.ini, or make the file readable." +msgstr "" + +#: src/coop/net/end_reason.cpp +msgid "Your net.ice=relay allows only a relayed connection, and this session has no relay server." +msgstr "" + +#: src/coop/net/end_reason.cpp +msgid "Your net.ice=relay keeps your address from other players, and a direct connection would show it to the host. Join a lobby that is not direct, or remove net.ice from multivoid.ini." +msgstr "" + +#: src/ui/scoreboard.cpp +#, c-format +msgid "Your player id %s" +msgstr "" + +#: src/ui/admin_panel.cpp +msgid "banned" +msgstr "" + +#: src/coop/bug_report/report_files.cpp +msgid "could not be read" +msgstr "" + +#: src/ui/skins_panel.cpp +#, c-format +msgid "current: %s" +msgstr "" + +#: src/ui/server_browser_panels.cpp +msgid "direct" +msgstr "" + +#: src/ui/browser_input_screens.cpp +msgid "host or host:port" +msgstr "" + +#: src/ui/config_review_panel.cpp +msgid "multivoid.ini could not be read" +msgstr "" + +#: src/ui/config_review_panel.cpp +msgid "multivoid.ini could not be read earlier this launch (a zero byte or a UTF-16 byte-order mark) and reads now. Settings read while it could not used their defaults; restart so every setting reads the file." +msgstr "" + +#: src/ui/config_review_panel.cpp +msgid "multivoid.ini could not be read earlier this launch (locked or failing) and reads now. Settings read while it could not used their defaults; restart so every setting reads the file." +msgstr "" + +#: src/ui/config_review_panel.cpp +msgid "multivoid.ini exists but could not be read (locked or failing)." +msgstr "" + +#: src/ui/config_review_panel.cpp +msgid "multivoid.ini holds a zero byte or starts with a UTF-16 byte-order mark, as a file saved as UTF-16 (\"Unicode\") does. Save it again as UTF-8." +msgstr "" + +#: src/ui/config_review_panel.cpp +#, c-format +msgid "" +"multivoid.ini was checked against the known settings at launch.\n" +"Nothing was changed automatically -- broken values fall back to\n" +"their defaults for this session, except the settings that refuse\n" +"instead (%s). The buttons below edit the file only when you click\n" +"them. This notice returns next launch while anything is still off." +msgstr "" + +#: src/ui/link_format.cpp +msgid "n/a" +msgstr "" + +#: src/ui/admin_panel.cpp +msgid "not blocked" +msgstr "" + +#: src/coop/bug_report/report_files.cpp +msgid "not present" +msgstr "" + +#: src/ui/server_settings_pane.cpp +#, c-format +msgid "now: %s" +msgstr "" + +#: src/ui/scoreboard.cpp +msgid "offline" +msgstr "" + +#: src/ui/admin_panel.cpp +msgid "other build" +msgstr "" + +#: src/ui/server_browser_panels.cpp +msgid "p2p" +msgstr "" + +#: src/ui/browser_input_screens.cpp +msgid "password" +msgstr "" + +#: src/ui/net_stats_panel.cpp +#, c-format +msgid "pkt/s %1$.0f in %2$.0f out" +msgstr "" + +#: src/ui/admin_panel.cpp +msgid "reason (shown in the Banned list)" +msgstr "" + +#: src/ui/dev_menu.cpp +msgid "starting up" +msgstr "" + +#: src/ui/loading_screen.cpp +msgid "the host" +msgstr "" + +#: src/ui/admin_panel.cpp +msgid "this player" +msgstr "" + +#: src/ui/console.cpp +msgid "type a command -- 'help'" +msgstr "" + +#: src/ui/server_browser_panels.cpp +msgid "unknown" +msgstr "" + +#: src/ui/voice_panel.cpp +msgid "unlimited" +msgstr "" + +#: src/ui/server_browser_panels.cpp +#, c-format +msgid "updated %ds ago" +msgstr "" + +#: src/ui/server_browser_panels.cpp +msgid "updated just now" +msgstr "" + +#: src/ui/scoreboard.cpp +#, c-format +msgid "volume %.2fx" +msgstr "" + +#: src/coop/bug_report/report_files.cpp +msgid "written by a build with another log format" +msgstr "" + +#: src/ui/browser_input_screens.cpp +msgid "your name" +msgstr "" + +#: src/ui/server_settings_pane.cpp +msgctxt "f1" +msgid "Apply" +msgstr "" + +#: src/ui/admin_panel.cpp +msgctxt "f1" +msgid "Ban" +msgstr "" + +#: src/ui/admin_panel.cpp +msgctxt "f1" +msgid "Ban..." +msgstr "" + +#: src/ui/admin_panel.cpp +msgctxt "f1" +msgid "Cancel" +msgstr "" + +#: src/ui/admin_panel.cpp +msgctxt "f1" +msgid "Kick" +msgstr "" + +#: src/ui/world_rules_panel.cpp +msgctxt "f1" +msgid "Off" +msgstr "" + +#: src/ui/world_rules_panel.cpp +msgctxt "f1" +msgid "On" +msgstr "" + +#: src/ui/world_rules_panel.cpp +msgctxt "f1" +msgid "Refresh" +msgstr "" + +#: src/ui/server_settings_pane.cpp +msgctxt "f1" +msgid "Reset" +msgstr "" + +#: src/ui/admin_panel.cpp +msgctxt "f1" +msgid "Teleport" +msgstr "" + +#: src/ui/admin_panel.cpp +msgctxt "f1" +msgid "Unban" +msgstr "" + +#: src/ui/scoreboard.cpp +msgctxt "hud" +msgid "Ban" +msgstr "" + +#: src/ui/loading_screen.cpp src/ui/scoreboard.cpp +msgctxt "hud" +msgid "Cancel" +msgstr "" + +#: src/ui/config_review_panel.cpp +msgctxt "hud" +msgid "Dismiss" +msgstr "" + +#: src/ui/scoreboard.cpp +msgctxt "hud" +msgid "Kick" +msgstr "" + +#: src/ui/end_reason_dialog.cpp +msgctxt "hud" +msgid "OK" +msgstr "" + +#: src/ui/browser_input_screens.cpp src/ui/server_browser_native.cpp +msgctxt "server_browser" +msgid "Back" +msgstr "" + +#: src/ui/browser_input_screens.cpp src/ui/server_browser_actions.cpp +msgctxt "server_browser" +msgid "Connect" +msgstr "" + +#: src/ui/browser_input_screens.cpp +msgctxt "server_browser" +msgid "Join" +msgstr "" + +#: src/ui/browser_input_screens.cpp +msgctxt "server_browser" +msgid "OK" +msgstr "" + +#: src/coop/session/server_settings_sync.cpp +msgctxt "setting_value" +msgid "off" +msgstr "" + +#: src/coop/session/server_settings_sync.cpp +msgctxt "setting_value" +msgid "on" +msgstr "" From aa9863ea5651d95407b9300863610baf275c6d6f Mon Sep 17 00:00:00 2001 From: pelmentor Date: Sat, 10 Oct 2026 07:20:52 +0600 Subject: [PATCH 094/306] [l10n] the Chinese pack and the translator's guide, as gettext The translations of the previous commit become locale/zh_CN.po, the pack the mod embeds: 80 of the template's 421 lines carry over, each matched by its English with argument numbers aside, none refused by the conversion check. Lines whose English changed (a split sentence now one), the developer panes' and the host flow's are left empty for a translator. The header names the translator and says the draft was machine-assisted and reviewed in game. The guide becomes locale/README.md, rewritten for .po files, Poedit and the override folder, the code table folded in. The JSON files go. Co-Authored-By: Slintchen <80808676+Slintchen@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 --- .github/ci/public_prose_baseline.json | 2 +- src/votv-coop/locale/README.md | 78 ++ src/votv-coop/locale/zh_CN.po | 1451 +++++++++++++++++++++++++ src/votv-coop/trans/LANGUAGE-CODES.md | 44 - src/votv-coop/trans/README.md | 57 - src/votv-coop/trans/ZH-CN.json | 219 ---- 6 files changed, 1530 insertions(+), 321 deletions(-) create mode 100644 src/votv-coop/locale/README.md create mode 100644 src/votv-coop/locale/zh_CN.po delete mode 100644 src/votv-coop/trans/LANGUAGE-CODES.md delete mode 100644 src/votv-coop/trans/README.md delete mode 100644 src/votv-coop/trans/ZH-CN.json diff --git a/.github/ci/public_prose_baseline.json b/.github/ci/public_prose_baseline.json index a702ffbbb..2ae9472ab 100644 --- a/.github/ci/public_prose_baseline.json +++ b/.github/ci/public_prose_baseline.json @@ -2,7 +2,7 @@ "as_of": "860892917", "note": "measured on the working tree; as_of is HEAD at that moment", "counters": { - "md.files": 50, + "md.files": 51, "md.lines": 7696, "md.over_600": 0, "md.cyrillic": 1, diff --git a/src/votv-coop/locale/README.md b/src/votv-coop/locale/README.md new file mode 100644 index 000000000..49d3939cc --- /dev/null +++ b/src/votv-coop/locale/README.md @@ -0,0 +1,78 @@ +# Translating Multivoid + +Turn the mod's interface into your own language -- **no programming, no rebuild**: copy one file, translate it, +name it after your language, drop it beside the game, restart. + +The files are gettext catalogues (`.po`), the format MTA and most open-source projects use, so any `.po` editor works: +[Poedit](https://poedit.net) is free and shows each English line beside the box for yours. + +## Three steps + +1. Take the template, `multivoid.pot`, from this folder of the mod's repository (`src/votv-coop/locale/`). + +2. Open it in Poedit (*Create new translation*, pick your language) or copy it to a file named after your + language's code -- see the table below, e.g. `ru.po`, `pt_BR.po` -- and fill in each `msgstr` with your text. + **Never change a `msgid`**: it is the English the mod looks your line up by. + +3. Put the file in a folder named `multivoid_locale` next to the game's executable: + + \WindowsNoEditor\VotV\Binaries\Win64\multivoid_locale\ru.po + + It sits next to `multivoid.ini` and `multivoid.log`; create the folder if it does not exist. Restart the game. + +## How your file is chosen + +At start-up the mod takes the language from `ui.language` in `multivoid.ini` -- `auto` (the default) is the language +Windows itself is displayed in -- and looks, from most to least specific, for `multivoid_locale/.po`, the pack +the mod ships for ``, `multivoid_locale/.po`, the pack for ``, and English. Your file overrides the +shipped pack line by line: a line you leave empty shows the pack's (or the English). To see what it settled on, open +`multivoid.log` and search for `l10n:` -- one line names the language, why it was chosen, and every file it read. + +## What must stay exactly as it is + +| Item | Example | Why | +|---|---|---| +| the conversions | `%s` `%d` `%llu` `%.1f` | the mod fills these in. Keep every one, with its type; their order may follow your language if you number them: `%2$s ... %1$s` | +| `%1$s` in a player's action | `%1$s deleted an email: %2$s` | `%1$s` is the player's name, coloured in the chat; put it wherever your sentence needs it | +| the context | `msgctxt "server_browser"` | the same English can need two translations; Poedit shows it, and it must not be edited | +| a literal percent sign | `50%%` | in a line that has conversions, `%%` prints one `%`; a line with no conversions may not contain `%` at all | +| positional words | "set a password below" | English's below/above describes English's layout -- **check the real screen** | + +A wrong line cannot break anything: the mod refuses it, shows the English instead, and writes the reason and the line +number to `multivoid.log`. A whole file that cannot be read (not UTF-8, a string that never closes) is refused the same +way and said once. + +## Plural forms + +A line about a count has a singular and a plural English form and one box per form your language has. Poedit fills the +header's `Plural-Forms` for your language when you create the translation; keep it, since the mod evaluates it to pick +the form for each number. + +## Language codes + +**The file name is the code**, the form gettext uses: the language in lower case, an underscore, the region in upper +case. + +| File | Language | | File | Language | +|---|---|---|---|---| +| `zh_CN.po` | Simplified Chinese (ships with the mod) | | `zh_TW.po` | Traditional Chinese | +| `ja.po` | Japanese | | `ko.po` | Korean | +| `de.po` | German | | `fr.po` | French | +| `es.po` | Spanish | | `it.po` | Italian | +| `pt_BR.po` | Brazilian Portuguese | | `pt.po` | Portuguese | +| `ru.po` | Russian | | `uk.po` | Ukrainian | +| `pl.po` | Polish | | `cs.po` | Czech | +| `nl.po` | Dutch | | `sv.po` | Swedish | +| `tr.po` | Turkish | | `hu.po` | Hungarian | + +Any language Windows can report works: the mod tries `` first, then ``, then English. + +## Notes + +- The file must be **UTF-8**; a byte-order mark is tolerated. +- Write a space as an ordinary space: the ideographic space (U+3000) is never drawn, so a name cannot hide behind it. +- The interface draws the scripts its fonts carry -- Latin, Cyrillic, Greek and more -- and Simplified Chinese through a + font Windows already has. Traditional Chinese, Japanese and Korean draw as boxes until a font for them is added to the + mod (one table row); a right-to-left or complex script (Arabic, Hebrew, Thai, Devanagari) loads but does not shape + correctly. +- `ui.language` takes effect at the next start. diff --git a/src/votv-coop/locale/zh_CN.po b/src/votv-coop/locale/zh_CN.po new file mode 100644 index 000000000..c04a58bd8 --- /dev/null +++ b/src/votv-coop/locale/zh_CN.po @@ -0,0 +1,1451 @@ +# Simplified Chinese (zh_CN) for the Multivoid interface. +# Translated by Slintchen (PR #41): a draft made with a local model, reviewed in game by the translator, +# converted from that request's ZH-CN.json for the entries whose English is unchanged. +msgid "" +msgstr "" +"Project-Id-Version: Multivoid\n" +"Language: zh_CN\n" +"Last-Translator: Slintchen <80808676+Slintchen@users.noreply.github.com>\n" +"Language-Team: Chinese (Simplified)\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" + +#, c-format +msgid "%.0f cm" +msgstr "" + +#, c-format +msgid "%.1f / %.1f MB" +msgstr "" + +#, c-format +msgid "%1$s (%2$llu KB)" +msgstr "" + +#, c-format +msgid "%1$s (last %2$llu KB of %3$llu KB)" +msgstr "" + +#, c-format +msgid "%1$s -- UPDATE AVAILABLE: %2$s" +msgstr "" + +#, c-format +msgid "%1$s -- left out: %2$s" +msgstr "" + +#, c-format +msgid "%1$s = '%2$s' (%3$s) -- %4$s; not in use, %5$s overrides it." +msgstr "" + +#, c-format +msgid "%1$s = '%2$s' (%3$s) -- %4$s; nothing it governs starts until it is fixed." +msgstr "" + +#, c-format +msgid "%1$s = '%2$s' (%3$s) -- %4$s; the built-in default is used." +msgstr "" + +#, c-format +msgid "%1$s = '%2$s' -- not a known setting (typo, or a retired key)." +msgstr "%s = '%s' —— 未知设置(拼错了,或已弃用)。" + +#, c-format +msgid "%1$s appears %2$llu time with different values; the FIRST line (line %3$d) wins. Pick which one to keep:" +msgid_plural "%1$s appears %2$llu times with different values; the FIRST line (line %3$d) wins. Pick which one to keep:" +msgstr[0] "" + +#, c-format +msgid "%1$s caught signal '%2$s'" +msgstr "" + +#, c-format +msgid "%1$s changed skin to %2$s" +msgstr "" + +#, c-format +msgid "%1$s could not order: an item was not in the host's store" +msgstr "" + +#, c-format +msgid "%1$s could not order: the delivery could not be placed" +msgstr "" + +#, c-format +msgid "%1$s could not order: the host could not read its store" +msgstr "" + +#, c-format +msgid "%1$s could not order: the host refused it" +msgstr "" + +#, c-format +msgid "%1$s could not order: there were not enough credits" +msgstr "" + +#, c-format +msgid "%1$s could not order: too many items in one order" +msgstr "" + +#, c-format +msgid "%1$s could not sell that" +msgstr "" + +#, c-format +msgid "%1$s could not sell that: it was already sold" +msgstr "" + +#, c-format +msgid "%1$s could not sell that: no coin gun exists in the host's world" +msgstr "" + +#, c-format +msgid "%1$s could not sell that: the host does not have it" +msgstr "" + +#, c-format +msgid "%1$s could not sell that: the host does not see you next to it" +msgstr "" + +#, c-format +msgid "%1$s could not sell that: the host hit an internal error" +msgstr "" + +#, c-format +msgid "%1$s could not sell that: the host's store will not take it" +msgstr "" + +#, c-format +msgid "%1$s deleted an email: %2$s" +msgstr "" + +#, c-format +msgid "%1$s is connecting to the game..." +msgstr "" + +#, c-format +msgid "%1$s is using %2$s" +msgstr "" + +#, c-format +msgid "%1$s joined the game" +msgstr "" + +#, c-format +msgid "%1$s left the game" +msgstr "" + +#, c-format +msgid "%1$s sold it for %2$lld point (the host's price)" +msgid_plural "%1$s sold it for %2$lld points (the host's price)" +msgstr[0] "" + +#, c-format +msgid "%1$s was turned away: Game version mismatch: server plays VOTV %2$s, client has VOTV %3$s. [%4$s]" +msgstr "" + +#, c-format +msgid "%1$s was turned away: malformed join (version field missing) [%2$s]" +msgstr "" + +#, c-format +msgid "%1$s was turned away: official build %2$s [%3$s]" +msgstr "" + +#, c-format +msgid "%1$s was turned away: unofficial build %2$s [%3$s]" +msgstr "" + +#, c-format +msgid "%1$u/%2$u players sleeping" +msgstr "" + +#, c-format +msgid "%d LAN" +msgstr "" + +#, c-format +msgid "%d direct" +msgstr "" + +#, c-format +msgid "%d online" +msgstr "" + +#, c-format +msgid "%d relayed" +msgstr "" + +#, c-format +msgid "%llu rule in force differs from the rules saved with this world." +msgid_plural "%llu rules in force differ from the rules saved with this world." +msgstr[0] "" + +#, c-format +msgid "%llu s in this step" +msgstr "" + +#, c-format +msgid "%llu server" +msgid_plural "%llu servers" +msgstr[0] "" + +#, c-format +msgid "%s (without passwords)" +msgstr "" + +#, c-format +msgid "%u / %u objects" +msgstr "" + +msgid "(hidden -- friends join by invite/IP)" +msgstr "" + +msgid "(locked)" +msgstr "" + +msgid "(no description)" +msgstr "无描述" + +msgid "(no nick)" +msgstr "" + +msgid "(no preview)" +msgstr "" + +msgid "(others can find your game)" +msgstr "" + +msgid "(system default)" +msgstr "" + +msgid "(unnamed)" +msgstr "" + +msgid "(you)" +msgstr "(你)" + +msgid "(your server)" +msgstr "" + +msgid "" +"A compact live panel in the top-right corner: receive/send rate right\n" +"now (wire-level, includes protocol overhead), total downloaded/uploaded\n" +"this session, packets/s, peers + ping, and a 60 s rate graph.\n" +"Works for host and clients. Saved to multivoid.ini (ui.netstats)." +msgstr "右上角实时面板:收发速率(链路层,含协议开销)、本次会话累计下载/上传、包/秒、玩家数与延迟,以及 60 秒速率曲线。房主和客户端都能用。保存到 multivoid.ini(ui.netstats)。" + +msgid "A new game can take a moment to load" +msgstr "" + +msgid "A newer connection with your identity replaced this one." +msgstr "" + +msgid "A pak that carries no skin (another mod's) is dropped from this list once the game has been asked -- it is not a skin." +msgstr "不含皮肤的 pak(其他模组的)在游戏扫描后会被剔除——它不是皮肤。" + +msgid "A player" +msgstr "" + +msgid "A proof arrived before a hello." +msgstr "" + +msgid "A second hello arrived on one connection." +msgstr "" + +msgid "Actions" +msgstr "" + +msgid "Activation" +msgstr "" + +msgid "Administration" +msgstr "" + +msgid "Allow other builds" +msgstr "" + +msgid "Also refuse their address" +msgstr "" + +msgid "Also refuses anyone connecting from the same address -- people who share their router or their provider's address are refused too. It only works on a direct connection: a player who comes through a relay is refused by their identity alone." +msgstr "" + +msgid "As a client you see only what the host allows you." +msgstr "" + +msgid "Ban (permanent)" +msgstr "封禁(永久)" + +msgid "Ban player" +msgstr "" + +msgid "Banned" +msgstr "" + +msgid "Body skin not saved" +msgstr "" + +msgid "COULD NOT CONNECT" +msgstr "" + +#, c-format +msgid "Cannot reach the %s server list. Check your connection." +msgstr "" + +#, c-format +msgid "Cannot reach the %s server list. Try another list above, or check your connection." +msgstr "" + +msgid "Change name" +msgstr "修改名称" + +msgid "Chat" +msgstr "" + +msgid "Click on the name of the rule to see its description." +msgstr "点击规则名称查看说明。" + +msgid "Click to mute your mic" +msgstr "" + +#, c-format +msgid "Code %s -- include it in a bug report" +msgstr "" + +msgid "" +"Colors your nick everywhere it shows -- nameplate, chat, player list --\n" +"on every peer's screen. Synced live and to late joiners; persists." +msgstr "" + +msgid "Confirm ban" +msgstr "" + +#, c-format +msgid "Connecting to %1$s's game..." +msgstr "" + +#, c-format +msgid "Connecting to %s" +msgstr "" + +#, c-format +msgid "Connection: %s" +msgstr "" + +msgid "Console" +msgstr "" + +msgid "Contact (optional, e.g. a Discord name)" +msgstr "" + +msgid "Cosmetics" +msgstr "" + +msgid "Could not compute the password proof on this machine." +msgstr "" + +msgid "Could not connect to that address -- check it, or another action is already in flight." +msgstr "" + +msgid "Could not find the log." +msgstr "" + +msgid "Could not reach the server list." +msgstr "" + +msgid "Could not read the log." +msgstr "" + +msgid "Could not rewrite multivoid.ini (it is missing, or the write failed) -- nothing was changed." +msgstr "无法重写 multivoid.ini(文件不存在或写入失败)——未做任何改动。" + +msgid "Could not rewrite multivoid.ini (locked or failing) -- nothing was changed. Close whatever holds the file and press Tidy up again." +msgstr "无法重写 multivoid.ini(被锁定或失败)——什么都没有改动。关掉占用该文件的程序,再按一次「整理」。" + +msgid "Could not rewrite multivoid.ini: it holds a zero byte or a UTF-16 byte-order mark -- nothing was changed. Save it again as UTF-8 and press Tidy up again." +msgstr "无法重写 multivoid.ini:它是零字节文件或带 UTF-16 字节序标记。请另存为 UTF-8,再点一次「整理」。" + +msgid "Could not send the command." +msgstr "" + +msgid "Could not send the identity proof to the host." +msgstr "" + +msgid "Could not start that connection -- another action is already in flight." +msgstr "" + +msgid "Could not start the connection." +msgstr "" + +msgid "Could not start the identity exchange with this host." +msgstr "" + +msgid "Could not start the report." +msgstr "" + +msgid "Could not write the report file." +msgstr "" + +msgid "Custom nickname color" +msgstr "自定义昵称颜色" + +msgid "DIRECT" +msgstr "" + +msgid "DISCONNECTED" +msgstr "" + +msgid "Developer tools are hidden. Set [dev] devkeys=1 in multivoid.ini to show them." +msgstr "开发者工具已隐藏。在 multivoid.ini 里设 [dev] devkeys=1 可显示。" + +msgid "" +"Device changes apply immediately (the voice engine\n" +"reopens on the game's next tick)." +msgstr "设备改动立即生效(语音引擎在下一帧重开)。" + +msgid "Devices" +msgstr "" + +msgid "Direct" +msgstr "" + +msgid "Direct connect" +msgstr "直连" + +msgid "Disconnected now and refused whenever they rejoin." +msgstr "" + +#, c-format +msgid "Down: %1$s now, %2$s total, %3$llu packet" +msgid_plural "Down: %1$s now, %2$s total, %3$llu packets" +msgstr[0] "" + +msgid "Downloading the host's world" +msgstr "" + +msgid "" +"Dr. Kel\n" +"(default)" +msgstr "" + +msgid "Duplicate settings with different values" +msgstr "" + +msgid "Each surface picks its own family; applies on the next frame." +msgstr "" + +msgid "Everyone in the registry is online." +msgstr "" + +msgid "Everyone is asleep -- the night passes..." +msgstr "" + +msgid "Everything scales with the screen automatically; this is your extra zoom." +msgstr "" + +msgid "Exchanging identities with the host" +msgstr "" + +msgid "Files that will be included" +msgstr "" + +#, c-format +msgid "Finding a route to %s" +msgstr "" + +msgid "Gamemode:" +msgstr "游戏模式:" + +msgid "Good morning -- everyone is rested." +msgstr "" + +#, c-format +msgid "Graphics API: %s" +msgstr "图形API:%s" + +msgid "HOST" +msgstr "" + +#, c-format +msgid "" +"Hold the key to talk. Change the key via %s\n" +"in multivoid.ini (single letter or a virtual-key number)." +msgstr "" + +msgid "" +"Host administration. Bans follow the player and survive restarts;\n" +"the registry remembers every player who ever joined this host." +msgstr "" + +msgid "Host game" +msgstr "创建房间" + +msgid "ID" +msgstr "" + +msgid "Interface" +msgstr "" + +#, c-format +msgid "Joined %1$s's game" +msgstr "" + +#, c-format +msgid "Joining %s" +msgstr "" + +#, c-format +msgid "Keep line %d: %s" +msgstr "" + +msgid "LAN" +msgstr "" + +msgid "Last seen" +msgstr "" + +#, c-format +msgid "Last seen: %ds ago" +msgstr "" + +msgid "Left the session." +msgstr "" + +msgid "Let players whose Multivoid build differs from this server's join -- an unofficial build when this server runs the official one, the official build when this server runs a modified one. Applies to the next joins; players already in stay." +msgstr "" + +msgid "Levels" +msgstr "" + +msgid "Link" +msgstr "" + +msgid "Loading the world" +msgstr "" + +#, c-format +msgid "Looking up %s" +msgstr "" + +msgid "MULTIPLAYER" +msgstr "" + +msgid "MULTIVOID SETTINGS CHECK" +msgstr "MULTIVOID 设置检查" + +msgid "Maximum voice audibility distance in centimeters (4800 = 48 m); 0 = unlimited (a whisper stays local); the host's setting in a session. A value of 1..99 is a near-mute range, the host's to choose." +msgstr "" + +msgid "Menu / panels" +msgstr "" + +msgid "Mic" +msgstr "" + +msgid "Mic gain" +msgstr "麦克风增益" + +msgid "Mic muted" +msgstr "" + +msgid "Microphone" +msgstr "" + +msgid "Multivoid - Change name" +msgstr "Multivoid - 修改名称" + +msgid "Multivoid - Direct connect" +msgstr "Multivoid - 直连" + +msgid "Multivoid - Password" +msgstr "Multivoid - 密码" + +msgid "Multivoid - Server Browser" +msgstr "Multivoid - 服务器浏览器" + +msgid "Mute for me" +msgstr "为我静音" + +msgid "Mute my microphone" +msgstr "静音我的麦克风" + +msgid "Muted for you (click for volume)" +msgstr "" + +#, c-format +msgid "NET %1$llu peer %2$d ms" +msgid_plural "NET %1$llu peers %2$d ms" +msgstr[0] "" + +#, c-format +msgid "NET %llu peer" +msgid_plural "NET %llu peers" +msgstr[0] "" + +msgid "NET offline" +msgstr "" + +msgid "Nameplate" +msgstr "" + +msgid "Nameplate: hidden from other players" +msgstr "" + +msgid "Nameplate: shown to other players" +msgstr "" + +msgid "Nameplates" +msgstr "" + +msgid "Net stats" +msgstr "" + +msgid "Network" +msgstr "" + +msgid "Nickname color" +msgstr "" + +msgid "Nickname color: applied (synced to other players)" +msgstr "" + +msgid "Nickname color: reset to default" +msgstr "" + +msgid "No active session." +msgstr "当前没有会话。" + +msgid "No answer from the host." +msgstr "" + +msgid "No banned players." +msgstr "暂无被封禁的玩家。" + +msgid "No microphone -- voice is receive-only." +msgstr "" + +msgid "No output device -- you can't hear voice." +msgstr "" + +msgid "No players." +msgstr "没有玩家。" + +msgid "No remote players connected." +msgstr "没有其他玩家连接。" + +msgid "No route to the host through its firewall or router." +msgstr "" + +msgid "No tools here yet -- coming soon." +msgstr "这里还没有工具 -- 近期推出。" + +msgid "Nobody in the registry yet (players are recorded when they join)." +msgstr "名单里还没有人(玩家加入时才会记录)。" + +msgid "" +"Off = your floating name/health bar disappears on every peer's\n" +"screen -- synced live and to late joiners; persists across sessions." +msgstr "" + +msgid "Off by default; persists across sessions." +msgstr "" + +msgid "Offline (seen before)" +msgstr "" + +msgid "Online" +msgstr "" + +msgid "Other" +msgstr "" + +msgid "Output" +msgstr "" + +msgid "Overlay fonts (per surface):" +msgstr "叠加层字体(分界面):" + +msgid "PLAYERS" +msgstr "玩家" + +msgid "Password (leave empty if the server has none)" +msgstr "密码(服务器没设密码就留空)" + +msgid "Peer action notifications" +msgstr "同伴动作提示" + +msgid "Peers WITHOUT that pak see the default kel body instead." +msgstr "没装该 pak 的玩家会看到默认 kel 身体。" + +#, c-format +msgid "Peers: %1$d, worst ping %2$d ms" +msgstr "同伴:%d,最差延迟 %d ms" + +#, c-format +msgid "Peers: %d" +msgstr "同伴:%d" + +#, c-format +msgid "Permanently ban %s?" +msgstr "永久封禁 %s?" + +msgid "Pick a server from the list first." +msgstr "" + +msgid "Ping" +msgstr "" + +msgid "Player" +msgstr "" + +#, c-format +msgid "" +"Player id %1$s\n" +"Address %2$s" +msgstr "" + +#, c-format +msgid "Player id %s" +msgstr "" + +msgid "Players" +msgstr "" + +#, c-format +msgid "Players: %d/%d" +msgstr "" + +msgid "Please describe what happened (at least 20 characters)." +msgstr "" + +msgid "Preview tile = .png/.bmp beside the pak." +msgstr "" + +#, c-format +msgid "Push-to-talk (key: %s)" +msgstr "" + +msgid "RELAY" +msgstr "" + +msgid "Read-only. The host's save sets the rules for everyone." +msgstr "只读。房主的存档决定所有人的规则。" + +msgid "Reading rules..." +msgstr "" + +msgid "Reason" +msgstr "" + +msgid "Receiving world from the host" +msgstr "" + +msgid "Refresh list" +msgstr "刷新列表" + +msgid "Refreshing the server list..." +msgstr "" + +msgid "Refused whenever they rejoin." +msgstr "" + +msgid "Relay" +msgstr "" + +msgid "Release toast" +msgstr "" + +msgid "Remote player" +msgstr "" + +msgid "Report" +msgstr "" + +msgid "Report a bug" +msgstr "" + +msgid "Rescan devices" +msgstr "重新扫描设备" + +#, c-format +msgid "" +"Rewrites multivoid.ini in the standard layout: [net] first, [dev]\n" +"last, each setting under its section, exact-duplicate lines merged.\n" +"Unknown settings and invalid values are commented out (kept in the\n" +"file, just disabled) -- that resolves their warnings above. An\n" +"invalid value of a setting that refuses instead (%s) is kept as\n" +"it is: disabling it would switch that setting to its default.\n" +"Conflicting duplicates are NEVER auto-resolved -- use the keep-line\n" +"buttons above. Your comments travel with their settings." +msgstr "按标准布局重写 multivoid.ini:[net] 在前、[dev] 在后,每个设置归到自己的分区,完全重复的行合并。未知设置与无效值会被注释掉(保留在文件里,只是禁用),这样就清掉了上面的警告。遇到「拒绝回退」的设置而值无效时(%s),保持原样——禁用它等于切回默认值。互相冲突的重复行绝不会自动裁决,请用上面的「保留哪一行」按钮。你的注释会跟着对应设置一起搬。" + +msgid "Rule" +msgstr "" + +msgid "Rules" +msgstr "" + +msgid "Save report" +msgstr "" + +msgid "" +"Saved to multivoid.ini (ui.font.menu/chat/net/nameplate/toast,\n" +"each with its own default). Fixedsys (VOTV) = game terminal pixel\n" +"font; JetBrains/Cascadia monospace; Roboto proportional." +msgstr "" + +#, c-format +msgid "Saved: %1$s (%2$llu KB)" +msgstr "" + +msgid "Saves a report file on this PC. Nothing is sent." +msgstr "" + +msgid "Saving the report..." +msgstr "" + +msgid "" +"Scales the WHOLE overlay (menus, chat, nameplates) on top of the\n" +"automatic resolution scale. Applies when you release the slider\n" +"(the fonts re-bake). Saved to multivoid.ini (ui.scale)." +msgstr "按自动分辨率缩放整个叠加层(菜单、聊天、名牌)。松开滑块时生效(字体会重新烘焙)。保存至 multivoid.ini(ui.scale)。" + +msgid "Select a category on the left." +msgstr "在左侧选择类别。" + +msgid "Select a server" +msgstr "选择一个服务器" + +msgid "Server address" +msgstr "服务器地址" + +msgid "Server info:" +msgstr "服务器信息:" + +msgid "Server password" +msgstr "服务器密码" + +#, c-format +msgid "Server setting changed: %1$s is now %2$s." +msgstr "" + +msgid "Server settings" +msgstr "" + +msgid "Session" +msgstr "" + +msgid "" +"Show a chat line when another player does a shared action\n" +"(e.g. deletes an email). Local preference; persists across\n" +"sessions (ui.chat.peer_actions)." +msgstr "" + +msgid "Show in folder" +msgstr "" + +msgid "Show in server browser" +msgstr "显示在服务器浏览器中" + +msgid "Show my nameplate to other players" +msgstr "向其他玩家显示我的名牌" + +msgid "Show network stats overlay" +msgstr "显示网络状态叠加层" + +#, c-format +msgid "Showing the %s server list." +msgstr "" + +#, c-format +msgid "Skin '%s' is not installed here -- keeping your current one" +msgstr "" + +#, c-format +msgid "Skin: %s" +msgstr "" + +msgid "Skins" +msgstr "" + +msgid "Skins come from converter .pak files under Content/Paks/LogicMods (any subfolder). One pak may carry several skins -- the four scientists ship together in scientists.pak." +msgstr "皮肤来自 Content/Paks/LogicMods 下的 .pak 文件(任意子文件夹)。一个 pak 可含多个皮肤——四个科学家就打包在 scientists.pak 里。" + +msgid "Sleep interrupted -- everyone wakes up." +msgstr "" + +msgid "Something was sent before the identity was proved." +msgstr "" + +msgid "" +"Speech louder than this transmits. Watch the mic\n" +"meter: set the slider just above your room's noise." +msgstr "只有比这个阈值响的语音才会发送。看着麦克风条:把滑块调到略高于环境噪音的位置。" + +msgid "Starting your server" +msgstr "" + +#, c-format +msgid "Starting your server -- loading %s" +msgstr "" + +msgid "Stats" +msgstr "" + +msgid "Takes effect at the next session." +msgstr "" + +msgid "Talking" +msgstr "" + +msgid "Teleport to me" +msgstr "传送到我这里" + +msgid "That line is too long." +msgstr "" + +msgid "That text is too long." +msgstr "" + +msgid "That's your own server -- you're already hosting it." +msgstr "" + +msgid "The ban file has entries this version cannot read, so bans made now last until the server stops. See the log." +msgstr "" + +msgid "The connection fell too far behind and was closed." +msgstr "" + +msgid "The connection was lost." +msgstr "" + +msgid "The download of the host's world stopped part way through." +msgstr "" + +msgid "The game is closing." +msgstr "" + +msgid "The game is shutting down." +msgstr "" + +msgid "The host banned you." +msgstr "" + +msgid "The host closed the connection." +msgstr "" + +msgid "The host closed the connection: it fell too far behind." +msgstr "" + +msgid "The host could not accept the connection." +msgstr "" + +msgid "The host could not check the password." +msgstr "" + +msgid "The host could not prove its identity." +msgstr "" + +msgid "The host could not read your hello." +msgstr "" + +msgid "The host could not read your proof." +msgstr "" + +msgid "The host could not send its challenge." +msgstr "" + +msgid "The host did not answer through the signaling server. It may be offline, or reconnecting -- try again in a minute." +msgstr "" + +msgid "The host ended the session." +msgstr "" + +msgid "The host has no randomness to challenge with." +msgstr "" + +msgid "The host is settling its world" +msgstr "" + +msgid "The host kicked you." +msgstr "" + +msgid "The host never finished preparing its world for you." +msgstr "" + +msgid "The host never sent you the world it owed." +msgstr "" + +msgid "The host never sent your inventory." +msgstr "" + +msgid "The host plays a different version of the game." +msgstr "" + +msgid "The host received a malformed packet." +msgstr "" + +msgid "The host runs a newer build of the mod. Update to join." +msgstr "" + +msgid "The host runs an older build of the mod. They need to update." +msgstr "" + +msgid "The host sent an identity challenge this build cannot read." +msgstr "" + +msgid "The host was busy, and this connection was the slowest to prove itself." +msgstr "" + +msgid "The host's world arrived damaged and could not be used." +msgstr "" + +msgid "The host's world would not load on this machine." +msgstr "" + +msgid "The identity proof did not arrive in time." +msgstr "" + +msgid "The join failed before it started. The log has the error." +msgstr "" + +msgid "The report could not be made." +msgstr "" + +msgid "The server is full." +msgstr "" + +msgid "The server list gave a bad address for this host." +msgstr "" + +msgid "The signaling server could not reach the host." +msgstr "" + +msgid "The transport handshake with the host failed." +msgstr "" + +msgid "Their identity is not proved yet." +msgstr "" + +msgid "This key holds your body skin -- if the winning value is not the one you expect, keep the other line." +msgstr "" + +#, c-format +msgid "This launch runs on environment overrides and built-in defaults; the settings that refuse instead of falling back (%s) refuse what they govern until the file can be read." +msgstr "" + +msgid "This machine has no working connection to the signaling server, so the host could not be dialled." +msgstr "" + +msgid "" +"This rule is achievement-locked!\n" +"Unlock this rule by getting following achievements:" +msgstr "" + +#, c-format +msgid "" +"This rule is day-locked!\n" +"Unlock this rule by playing past day %d" +msgstr "" + +msgid "This server admits only the official Multivoid build. Install the official release (the whole zip)." +msgstr "" + +msgid "This server has no settings to change here." +msgstr "" + +msgid "This server needs a password, and nothing told this machine which host it was dialling." +msgstr "" + +msgid "This server needs a password." +msgstr "" + +msgid "This server runs a modified (unofficial) Multivoid build and admits only modified builds." +msgstr "" + +msgid "Threshold" +msgstr "阈值" + +msgid "Threshold near 0 dB -- the mic will almost never trigger. Try -50 dB." +msgstr "" + +#, c-format +msgid "Tidied: %d duplicate line(s) merged, %d setting(s) placed under their sections, %d unknown/invalid line(s) commented out. Anything still listed above needs a choice or can't be fixed from the file." +msgstr "整理完成:合并 %d 条重复行,%d 个设置归位,%d 条未知或无效行已注释。上面还列着的,需要你选择,或无法从文件里修复。" + +msgid "Tidy up multivoid.ini" +msgstr "" + +msgid "Too many connections from you in a short time. Try again shortly." +msgstr "" + +msgid "Too many password attempts. Try again in ten minutes." +msgstr "" + +msgid "Type /help for the commands." +msgstr "" + +msgid "Type a name first." +msgstr "" + +msgid "Type an address first -- host or host:port." +msgstr "" + +msgid "Type the password this server was locked with." +msgstr "" + +msgid "UI size" +msgstr "" + +msgid "Unknown settings (nothing reads these)" +msgstr "" + +#, c-format +msgid "Up: %1$s now, %2$s total, %3$llu packet" +msgid_plural "Up: %1$s now, %2$s total, %3$llu packets" +msgstr[0] "" + +msgid "Update list" +msgstr "更新列表" + +msgid "V: voice settings" +msgstr "V: 语音设置" + +msgid "Value" +msgstr "" + +msgid "Values that could not be used" +msgstr "" + +#, c-format +msgid "Version: %1$s%2$s" +msgstr "" + +#, c-format +msgid "Version: %1$s%2$s -- built for a different game version" +msgstr "" + +#, c-format +msgid "Version: %1$s%2$s -- the host must update" +msgstr "" + +#, c-format +msgid "Version: %1$s%2$s -- you must update" +msgstr "" + +msgid "Voice activation" +msgstr "语音激活" + +msgid "Voice chat" +msgstr "" + +#, c-format +msgid "Voice chat is turned off (%s=0 in multivoid.ini). Set it to 1 and restart the game to use voice." +msgstr "" + +msgid "Voice connected (click for volume)" +msgstr "" + +msgid "Voice disconnected" +msgstr "" + +msgid "Voice off" +msgstr "" + +msgid "Voice range" +msgstr "" + +#, c-format +msgid "Voice range: %s" +msgstr "" + +#, c-format +msgid "Voice range: %s (set by the host)" +msgstr "" + +#, c-format +msgid "Voice range: %s (your server's; multivoid.ini until the settings page)" +msgstr "" + +msgid "Voice volume" +msgstr "语音音量" + +#, c-format +msgid "Waiting for the %s list..." +msgstr "" + +msgid "Waiting for the host to capture its world" +msgstr "" + +msgid "Waiting for the host's world" +msgstr "" + +msgid "What happened" +msgstr "" + +msgid "What you expected" +msgstr "" + +msgid "When" +msgstr "" + +msgid "Whispering" +msgstr "" + +msgid "World" +msgstr "" + +msgid "World rules unavailable (still loading?)." +msgstr "" + +#, c-format +msgid "World: %s" +msgstr "" + +msgid "Wrong password." +msgstr "" + +msgid "You are banned from this server." +msgstr "" + +#, c-format +msgid "Your body skin (%s) could not be saved to multivoid.ini this launch -- it is temporary for this session. Fix whatever keeps the file from being read or saved (a lock from an editor or antivirus, permissions, or a file saved as UTF-16); nothing is lost once a launch can save again." +msgstr "" + +msgid "Your body skin -- what YOU see looking down and what OTHERS see. Saved to multivoid.ini; restored on rejoin." +msgstr "" + +msgid "Your connection presented no key identity." +msgstr "" + +msgid "Your game loaded the world but never finished settling into it." +msgstr "" + +msgid "Your identity changed during the exchange." +msgstr "" + +msgid "Your identity proof did not verify." +msgstr "" + +msgid "Your mic is muted -- click to unmute" +msgstr "" + +msgid "Your mod build differs from the host's." +msgstr "" + +msgid "Your name" +msgstr "你的名字" + +#, c-format +msgid "Your name: %s" +msgstr "" + +msgid "Your net.ice setting could not be read as all or relay. Set it to one of them in multivoid.ini, or make the file readable." +msgstr "" + +msgid "Your net.ice=relay allows only a relayed connection, and this session has no relay server." +msgstr "" + +msgid "Your net.ice=relay keeps your address from other players, and a direct connection would show it to the host. Join a lobby that is not direct, or remove net.ice from multivoid.ini." +msgstr "" + +#, c-format +msgid "Your player id %s" +msgstr "" + +msgid "banned" +msgstr "已封禁" + +msgid "could not be read" +msgstr "" + +#, c-format +msgid "current: %s" +msgstr "当前:%s" + +msgid "direct" +msgstr "" + +msgid "host or host:port" +msgstr "主机或主机:端口" + +msgid "multivoid.ini could not be read" +msgstr "" + +msgid "multivoid.ini could not be read earlier this launch (a zero byte or a UTF-16 byte-order mark) and reads now. Settings read while it could not used their defaults; restart so every setting reads the file." +msgstr "" + +msgid "multivoid.ini could not be read earlier this launch (locked or failing) and reads now. Settings read while it could not used their defaults; restart so every setting reads the file." +msgstr "" + +msgid "multivoid.ini exists but could not be read (locked or failing)." +msgstr "multivoid.ini 存在但读不出来(被占用或读取失败)。" + +msgid "multivoid.ini holds a zero byte or starts with a UTF-16 byte-order mark, as a file saved as UTF-16 (\"Unicode\") does. Save it again as UTF-8." +msgstr "multivoid.ini 是零字节文件,或以 UTF-16 字节序标记开头(即用 UTF-16 保存过)。请另存为 UTF-8。" + +#, c-format +msgid "" +"multivoid.ini was checked against the known settings at launch.\n" +"Nothing was changed automatically -- broken values fall back to\n" +"their defaults for this session, except the settings that refuse\n" +"instead (%s). The buttons below edit the file only when you click\n" +"them. This notice returns next launch while anything is still off." +msgstr "multivoid.ini 在启动时已与已知设置核对过。没有自动改动任何东西——出错的值本次会话回退到默认值,但那些「拒绝回退」的设置除外(%s)。下面的按钮只有在你点击时才编辑文件。只要还有问题,这条提示下次启动还会出现。" + +msgid "n/a" +msgstr "" + +msgid "not blocked" +msgstr "" + +msgid "not present" +msgstr "" + +#, c-format +msgid "now: %s" +msgstr "" + +msgid "offline" +msgstr "" + +msgid "other build" +msgstr "" + +msgid "p2p" +msgstr "" + +msgid "password" +msgstr "" + +#, c-format +msgid "pkt/s %1$.0f in %2$.0f out" +msgstr "" + +msgid "reason (shown in the Banned list)" +msgstr "" + +msgid "starting up" +msgstr "" + +msgid "the host" +msgstr "" + +msgid "this player" +msgstr "" + +msgid "type a command -- 'help'" +msgstr "" + +msgid "unknown" +msgstr "" + +msgid "unlimited" +msgstr "" + +#, c-format +msgid "updated %ds ago" +msgstr "" + +msgid "updated just now" +msgstr "" + +#, c-format +msgid "volume %.2fx" +msgstr "" + +msgid "written by a build with another log format" +msgstr "" + +msgid "your name" +msgstr "你的名字" + +msgctxt "f1" +msgid "Apply" +msgstr "" + +msgctxt "f1" +msgid "Ban" +msgstr "" + +msgctxt "f1" +msgid "Ban..." +msgstr "" + +msgctxt "f1" +msgid "Cancel" +msgstr "取消" + +msgctxt "f1" +msgid "Kick" +msgstr "踢出" + +msgctxt "f1" +msgid "Off" +msgstr "" + +msgctxt "f1" +msgid "On" +msgstr "" + +msgctxt "f1" +msgid "Refresh" +msgstr "刷新" + +msgctxt "f1" +msgid "Reset" +msgstr "" + +msgctxt "f1" +msgid "Teleport" +msgstr "传送" + +msgctxt "f1" +msgid "Unban" +msgstr "解除封禁" + +msgctxt "hud" +msgid "Ban" +msgstr "" + +msgctxt "hud" +msgid "Cancel" +msgstr "取消" + +msgctxt "hud" +msgid "Dismiss" +msgstr "" + +msgctxt "hud" +msgid "Kick" +msgstr "踢出" + +msgctxt "hud" +msgid "OK" +msgstr "" + +msgctxt "server_browser" +msgid "Back" +msgstr "返回" + +msgctxt "server_browser" +msgid "Connect" +msgstr "连接" + +msgctxt "server_browser" +msgid "Join" +msgstr "加入" + +msgctxt "server_browser" +msgid "OK" +msgstr "" + +msgctxt "setting_value" +msgid "off" +msgstr "" + +msgctxt "setting_value" +msgid "on" +msgstr "" diff --git a/src/votv-coop/trans/LANGUAGE-CODES.md b/src/votv-coop/trans/LANGUAGE-CODES.md deleted file mode 100644 index ec2f3cac0..000000000 --- a/src/votv-coop/trans/LANGUAGE-CODES.md +++ /dev/null @@ -1,44 +0,0 @@ -# Language codes - -**The file name IS the tag.** Copy `example.json` to the name from the tables below and translate it. - -The tag is a BCP-47 language code, and it is what Windows reports for your system language -- -which is what the mod compares against, so an exact match is the common case. - -## Chinese - -| File | Language | -|---|---| -| `zh-CN.json` | Simplified Chinese (what a Chinese Windows returns) | -| `zh.json` | covers every Chinese locale as a fallback when the exact name is absent | -| `zh-TW.json` | Traditional Chinese (Taiwan) | -| `zh-HK.json` | Traditional Chinese (Hong Kong) | - -## Other common languages - -| File | Language | | File | Language | -|---|---|---|---|---| -| `en.json` | English (built in) | | `tr.json` | Turkish | -| `ja.json` | Japanese | | `hu.json` | Hungarian | -| `ko.json` | Korean | | `ro.json` | Romanian | -| `de.json` | German | | `vi.json` | Vietnamese | -| `fr.json` | French | | `th.json` | Thai | -| `es.json` | Spanish | | `id.json` | Indonesian | -| `pt-BR.json` | Brazilian Portuguese | | `ms.json` | Malay | -| `pt.json` | Portuguese | | `hi.json` | Hindi | -| `it.json` | Italian | | `ar.json` | Arabic | -| `ru.json` | Russian | | `fa.json` | Persian | -| `uk.json` | Ukrainian | | `he.json` | Hebrew | -| `pl.json` | Polish | | `cs.json` | Czech | -| `nl.json` | Dutch | | `sv.json` | Swedish | -| `da.json` | Danish | | `fi.json` | Finnish | -| `no.json` | Norwegian | | `el.json` | Greek | - -Any tag Windows can report works: the loader tries the exact name first, then the part before the -dash, then English. - -## Notes - -- The file must be **UTF-8**. A byte-order mark is tolerated and stripped, but plain UTF-8 is better. -- The mod only draws scripts its font layer can reach. CJK (`zh`, `ja`, `ko`) is wired up and - merges a system face; for other scripts, check the log -- a missing glyph is reported. diff --git a/src/votv-coop/trans/README.md b/src/votv-coop/trans/README.md deleted file mode 100644 index c187efbf6..000000000 --- a/src/votv-coop/trans/README.md +++ /dev/null @@ -1,57 +0,0 @@ -# Translating Multivoid - -Turn the mod's interface into your own language -- **no programming, no rebuild**: copy one file, -translate it, rename it, drop it beside the game, restart. - -## Three steps - -1. Find the `trans` folder in your game install: - - \WindowsNoEditor\VotV\Binaries\Win64\trans\ - - It sits next to `multivoid.ini` and `multivoid.log`. If it does not exist, create it. - -2. Copy `example.json` from this folder into that one, rename it to your language code - (see LANGUAGE-CODES.md -- e.g. `zh-CN.json`), and replace the English on the **right** with - your language. **Do not touch the left-hand side (the key).** - - { - "Multiplayer": "...", - "Host": "..." - } - -3. Restart the game. The interface is now in your language. - -## How your file is chosen - -At start-up the mod looks, in this order, for: - -1. `ui.language` in `multivoid.ini` (default `auto`, i.e. "not specified") -2. your **Windows system language** (`zh-CN`, `ja-JP`, `de-DE`, ...) -3. the exact name first (`zh-CN.json`), then the primary subtag (`zh.json`) -4. and if neither exists, the **built-in English** -- the UI is never blank and never shows a key name - -To see what it settled on, open `multivoid.log` and search for `i18n:`. That one line prints the -tag, the file it read, how many entries it kept, and which folder it searched. - -## What must stay exactly as it is - -| Item | Example | Why | -|---|---|---| -| printf placeholders | %s %d %zu %.0f | the code fills these in. The **count must match**; their order may follow your language | -| `##` markers | the `##banlist` in `"Ban List##banlist"` | that is ImGui's internal widget id; changing it breaks the panel | -| positional words | "set a password below" | English's below/above describes English's layout -- **check the real screen** instead of translating it literally | - -A wrong translation cannot harm anything: that single line falls back to English and a warning is -written to the log. An empty value means "not translated yet". - -## For maintainers - -- `example.json` is **generated** -- do not edit it by hand: - - regenerate: `python src/votv-coop/i18n-tools/extract.py` - - check template and source agree (CI-friendly): `... extract.py --check` -- draft a pack with a **local** model (no API cost): `... translate.py --lang zh-CN --tag ZH-CN` - It validates the placeholders and `##` markers of every line, and resumes after an interruption. -- wire new strings to the lookup: `... apply.py` (dry run; add `--write` to edit files). It only - touches strings that are already in the template, and skips `constexpr` tables -- a constant - expression cannot call a function, so those are translated where they are drawn. diff --git a/src/votv-coop/trans/ZH-CN.json b/src/votv-coop/trans/ZH-CN.json deleted file mode 100644 index 9c0c3a6f6..000000000 --- a/src/votv-coop/trans/ZH-CN.json +++ /dev/null @@ -1,219 +0,0 @@ -{ - "%s = '%s' -- not a known setting (typo, or a retired key).": "%s = '%s' —— 未知设置(拼错了,或已弃用)。", - "(both peers)": "双方:", - "(class + prop visual row)": "(类别 + 道具视觉行)", - "(host only)": "(仅房主)", - "(host spawns + syncs)": "主机生成 + 同步", - "(host/local only)": "(仅本地/房主)", - "(labels projected onto nearby objects)": "物体附近的投影标签", - "(lines between every bone of an ACTIVE ragdoll body)": "(当前活跃布娃娃体每根骨骼之间的连线)", - "(local balance -- afford drone orders)": "(本地平衡——可负担无人机订单)", - "(mirror test: watch the client radar)": "镜像测试:查看客户端雷达", - "(no description)": "无描述", - "(on-screen overlay)": "(屏幕叠加层)", - "(or press Q while enabled)": "(或在启用状态下按 Q)", - "(sim/rest + static/frozen/sleep)": "(模拟/休息 + 静态/冰冻/睡眠)", - "(sun %.3f)": "(太阳 %.3f)", - "(you)": "(你)", - "A compact live panel in the top-right corner: receive/send rate right\nnow (wire-level, includes protocol overhead), total downloaded/uploaded\nthis session, packets/s, peers + ping, and a 60 s rate graph.\nWorks for host and clients. Saved to multivoid.ini (ui.netstats).": "右上角实时面板:收发速率(链路层,含协议开销)、本次会话累计下载/上传、包/秒、玩家数与延迟,以及 60 秒速率曲线。房主和客户端都能用。保存到 multivoid.ini(ui.netstats)。", - "A pak that carries no skin (another mod's) is dropped from this list once the game has been asked -- it is not a skin.": "不含皮肤的 pak(其他模组的)在游戏扫描后会被剔除——它不是皮肤。", - "AUTOMATIC (recommended)": "自动(推荐)", - "AUTOMATIC (recommended)": "自动(推荐)", - "Anyone can find your game in the list.": "任何人都能在列表里看到你的房间。", - "Anyone can join": "任何人都可以加入", - "Anyone with this can join. Give it out the way you would give out an invite link.": "拿到它就能加入。像发邀请链接一样发出去就行。", - "Anyone with this can join. On this connection type give your friends your address and this password -- they type both into Direct Connect.": "拿到地址和密码就能加入。这种连接方式下,把地址和密码发给好友,让他们在「直连」里分别填入。", - "Arm + complete NOW: fires the event (clients get the arm as usual), then\ndrives %s's own overlap handler with your pawn -- the same dispatch a real\nwalk-in performs (native class filter + N bookkeeping).%s": "布防 + 立即完成:触发事件(客户端照常收到布防),然后用你的角色驱动 %s 自身的重叠处理——与真人走进来完全相同的分发(原生类别过滤 + N 记账)。%s", - "Automatic games are always listed -- the list is how friends find you. Set a password below to keep strangers out.": "自动模式的房间总会列出——好友正是靠它找到你。想挡住陌生人,就设个密码。", - "Ban (permanent)": "封禁(永久)", - "Busy -- another host or join is already starting.": "正忙——另一个开房或加入操作正在进行。", - "CONNECTION": "连接", - "Cancel": "取消", - "Change name": "修改名称", - "Click on the name of the rule to see its description.": "点击规则名称查看说明。", - "Colors your nick everywhere it shows -- nameplate, chat, player list --": "给所有显示昵称的地方上色——名牌、聊天、玩家列表——", - "Connect": "连接", - "Connection:": "连接:", - "Connection: ": "连接:", - "Connects directly when your network allows it, relays automatically": "网络允许时直连,否则自动中继", - "Could not generate a password on this system -- the lock stays off.": "本机无法生成密码——锁定未启用。", - "Could not rewrite multivoid.ini (it is missing, or the write failed) -- nothing was changed.": "无法重写 multivoid.ini(文件不存在或写入失败)——未做任何改动。", - "Could not rewrite multivoid.ini (locked or failing) -- nothing was changed. Close whatever holds the file and press Tidy up again.": "无法重写 multivoid.ini(被锁定或失败)——什么都没有改动。关掉占用该文件的程序,再按一次「整理」。", - "Could not rewrite multivoid.ini: it holds a zero byte or a UTF-16 byte-order mark -- nothing was changed. Save it again as UTF-8 and press Tidy up again.": "无法重写 multivoid.ini:它是零字节文件或带 UTF-16 字节序标记。请另存为 UTF-8,再点一次「整理」。", - "Custom nickname color": "自定义昵称颜色", - "DIRECT (you forward a port)": "直连(P2P)", - "DIRECT (you forward a port)": "直连(P2P)", - "Developer tools are disabled while connected as a client (host-only).": "以客户端身份连接时,开发者工具不可用(仅房主可用)。", - "Developer tools are hidden. Set [dev] devkeys=1 in multivoid.ini to show them.": "开发者工具已隐藏。在 multivoid.ini 里设 [dev] devkeys=1 可显示。", - "Device changes apply immediately (the voice engine\nreopens on the game's next tick).": "设备改动立即生效(语音引擎在下一帧重开)。", - "Direct connect": "直连", - "Direct connect:": "直连:", - "Disconnected now and blocked by IP on reconnect.": "已断开;重连时会被 IP 封禁拦截。", - "Enables the sandbox Q spawn menu in story mode.": "在故事模式下启用沙盒 Q 生成菜单。", - "Freecam": "自由视角", - "Friends on your own network can join with no forwarding at all.": "同一局域网的好友,无需任何端口转发即可加入。", - "Friends reach your PC. No server involved.": "朋友直连你的电脑,不经过任何服务器。", - "Game: %s": "游戏:%s", - "Gamemode:": "游戏模式:", - "GetDesiredSize": "获取期望大小", - "Graphics API: %s": "图形API:%s", - "Grouped by strict": "按严格分组", - "HOME also toggles - WASD move, Space/Ctrl up/down,": "HOME也切换- WASD移动,Space/Ctrl上下", - "Health / progress": "血量 / 进度", - "Hidden": "隐藏", - "Hidden: nothing is sent to the master server at any point.": "隐藏:全程不会向主服务器发送任何信息。", - "Hide from server browser (friends Direct Connect by IP)": "不在服务器浏览器中显示(好友用 IP 直连)", - "Hold the key to talk. Change the key via voice.ptt_key\nin multivoid.ini (single letter or a virtual-key number).": "按住该键说话。可在 multivoid.ini 的 voice.ptt_key 修改(单个字母或虚拟键码)。", - "Host Game - choose a world###coop_save_picker": "创建房间 - 选择一个世界###coop_save_picker", - "Host game": "创建房间", - "Host selected save": "以所选存档开房", - "Join": "加入", - "Kick": "踢出", - "Launch anyways": "仍然启动", - "Locked": "已锁定", - "MOD INSTALL PROBLEM": "MOD 安装问题", - "MULTIVOID SETTINGS CHECK": "MULTIVOID 设置检查", - "Master server: ": "主服务器:", - "Mic gain": "麦克风增益", - "Multiplayer": "多人模式", - "Multivoid - Change name": "Multivoid - 修改名称", - "Multivoid - Direct connect": "Multivoid - 直连", - "Multivoid - Host Game": "Multivoid - 创建房间", - "Multivoid - Password": "Multivoid - 密码", - "Multivoid - Server Browser": "Multivoid - 服务器浏览器", - "Multivoid - Session settings": "Multivoid - 会话设置", - "Mute for me": "为我静音", - "Mute my microphone": "静音我的麦克风", - "Net identity": "网络身份", - "New Game & Host": "新游戏并开房", - "New Game (Story):": "新游戏(故事):", - "New game": "新游戏", - "Next": "下一步", - "No active session.": "当前没有会话。", - "No banned players.": "暂无被封禁的玩家。", - "No players.": "没有玩家。", - "No remote players connected.": "没有其他玩家连接。", - "No stored IP for this player (P2P join) --\nnothing the IP ban filter could enforce against.": "该玩家没有记录 IP(P2P 加入)——IP 封禁无从执行。", - "No tools here yet -- coming soon.": "这里还没有工具 -- 近期推出。", - "Nobody in the registry yet (players are recorded when they join).": "名单里还没有人(玩家加入时才会记录)。", - "Object names": "对象名称", - "Off = your floating name/health bar disappears on every peer's": "关闭 = 你的浮动名字/血条在其他玩家屏幕上消失", - "Only friends you give your address to.": "只有拿到你地址的好友才能加入。", - "Only players you give the password to.": "只有输入密码才能加入。", - "Open spawn menu now": "立即打开生成菜单", - "Overlay fonts (per surface):": "叠加层字体(分界面):", - "PLAYERS": "玩家", - "Password": "密码", - "Password (leave empty if the server has none)": "密码(服务器没设密码就留空)", - "Password required": "需要密码", - "Peer action notifications": "同伴动作提示", - "Peers WITHOUT that pak see the default kel body instead.": "没装该 pak 的玩家会看到默认 kel 身体。", - "Peers: %d": "同伴:%d", - "Peers: %d, worst ping %d ms": "同伴:%d,最差延迟 %d ms", - "Permanently ban %s?": "永久封禁 %s?", - "Physics state": "物理状态", - "Pick a save to host, or start a New Game:": "选一个存档开房,或开始新游戏:", - "Position / camera readout": "位置 / 相机读取", - "Prop spawn menu in story mode (Q)": "故事模式道具生成菜单 (Q)", - "Ragdoll bone skeleton": "骨骼骨架", - "Read-only. The host's save sets the rules for everyone.": "只读。房主的存档决定所有人的规则。", - "Refresh": "刷新", - "Refresh list": "刷新列表", - "Requires UDP port %ld forwarded to this PC. Friends join from": "需要把 UDP 端口 %ld 转发到本机。好友从", - "Rescan devices": "重新扫描设备", - "Restore vitals (food / sleep / health)": "恢复生命体征(食物 / 睡眠 / 血量)", - "Return": "返回", - "Rewrites multivoid.ini in the standard layout: [net] first, [dev]\nlast, each setting under its section, exact-duplicate lines merged.\nUnknown settings and invalid values are commented out (kept in the\nfile, just disabled) -- that resolves their warnings above. An\ninvalid value of a setting that refuses instead (%s) is kept as\nit is: disabling it would switch that setting to its default.\nConflicting duplicates are NEVER auto-resolved -- use the keep-line\nbuttons above. Your comments travel with their settings.": "按标准布局重写 multivoid.ini:[net] 在前、[dev] 在后,每个设置归到自己的分区,完全重复的行合并。未知设置与无效值会被注释掉(保留在文件里,只是禁用),这样就清掉了上面的警告。遇到「拒绝回退」的设置而值无效时(%s),保持原样——禁用它等于切回默认值。互相冲突的重复行绝不会自动裁决,请用上面的「保留哪一行」按钮。你的注释会跟着对应设置一起搬。", - "SERVER LIST": "服务器列表", - "Saved to multivoid.ini (ui.font.menu/chat/net/nameplate/toast,": "保存到multivoid.ini (ui.font.menu/chat/net/nameplate/toast,", - "Scales the WHOLE overlay (menus, chat, nameplates) on top of the\nautomatic resolution scale. Applies when you release the slider\n(the fonts re-bake). Saved to multivoid.ini (ui.scale).": "按自动分辨率缩放整个叠加层(菜单、聊天、名牌)。松开滑块时生效(字体会重新烘焙)。保存至 multivoid.ini(ui.scale)。", - "Select a category on the left.": "在左侧选择类别。", - "Server address": "服务器地址", - "Server browser": "服务器浏览器", - "Server info:": "服务器信息:", - "Server password": "服务器密码", - "Set clock": "设置时钟", - "Set stamina low": "降低耐力", - "Shift fast, wheel speed, MMB bring player": "Shift 快速切换、滚轮调速、中键把玩家拉过来", - "Show a chat line when another player does a shared action": "其他玩家做出共享操作时在聊天里显示一行", - "Show in server browser": "显示在服务器浏览器中", - "Show my nameplate to other players": "向其他玩家显示我的名牌", - "Show network stats overlay": "显示网络状态叠加层", - "Skins come from converter .pak files under Content/Paks/LogicMods (any subfolder). One pak may carry several skins -- the four scientists ship together in scientists.pak.": "皮肤来自 Content/Paks/LogicMods 下的 .pak 文件(任意子文件夹)。一个 pak 可含多个皮肤——四个科学家就打包在 scientists.pak 里。", - "Snow": "雪", - "Spawn Eyer (in front)": "生成 Eyer(前方)", - "Spawn kerfurOmega (in front)": "生成 kerfurOmega(前方)", - "Spawn killerWisp (in front)": "生成 killerWisp(前方)", - "Spawn killerWisp ON client (coop kill test)": "在客户端生成 killerWisp(联机击杀测试)", - "Spawn ventCrawler (in front)": "生成 ventCrawler(前方)", - "Speech louder than this transmits. Watch the mic\nmeter: set the slider just above your room's noise.": "只有比这个阈值响的语音才会发送。看着麦克风条:把滑块调到略高于环境噪音的位置。", - "Sun position": "太阳位置", - "Teleport": "传送", - "Teleport clients to me": "把所有客户端传送到我这里", - "Teleport to me": "传送到我这里", - "This save was made in another version of the game.": "该存档来自另一个游戏版本。", - "Threshold": "阈值", - "Tidied: %d duplicate line(s) merged, %d setting(s) placed under their sections, %d unknown/invalid line(s) commented out. Anything still listed above needs a choice or can't be fixed from the file.": "整理完成:合并 %d 条重复行,%d 个设置归位,%d 条未知或无效行已注释。上面还列着的,需要你选择,或无法从文件里修复。", - "Type a password, or choose \"Anyone can join\".": "任何人都可以加入", - "Unban": "解除封禁", - "Up: %s now, %s total, %llu packets": "上传:当前 %s,总计 %s,%llu 个包", - "Update list": "更新列表", - "V: voice settings": "V: 语音设置", - "VOTV Coop - Menu (F1)": "VOTV Coop - 菜单 (F1)", - "Visual only (sun angle). The clock above is what events/save follow.": "仅视觉(太阳角度)。上面那个时钟才是事件/存档跟随的。", - "Voice activation": "语音激活", - "Voice chat is turned off (voice.enabled=0 in multivoid.ini). Set it to 1 and restart the game to use voice.": "语音聊天已关闭(multivoid.ini 里 voice.enabled=0)。改成 1 并重启游戏即可使用。", - "Voice chat###coop_voice_panel": "语音聊天###coop_voice_panel", - "Voice volume": "语音音量", - "WHO MAY JOIN": "谁可以加入", - "WORLD": "世界", - "We introduce you. Nothing to set up.": "我们替你引荐,无需任何设置。", - "World clock not resolved yet (enter a world).": "世界时钟尚未确定(先进入一个世界)。", - "World object overlay": "世界物体叠加层", - "World: ": "世界:", - "World: a new game": "世界:新游戏", - "Your name": "你的名字", - "Your name:": "你的名字:", - "Your session is open to everyone who finds it.": "任何人都可以直接加入。", - "Your session:": "你的房间:", - "[gate: volume ?]": "[gate: volume?]", - "[volume-gated]": "音量闸门", - "banned": "已封禁", - "category. 'day N' = unlock day. The cyan TIME column is the native trigger time-of-": "类别。'day N' = 解锁日。青色 TIME 列是原生触发时刻——", - "current: %s": "当前:%s", - "day (HH:MM anchor) / 'trigger' (story/build) / 'rep' (ariral-reputation prank pool).": "日(HH:MM 锚点)/ 'trigger'(剧情/建造)/ 'rep'(ariral 声望恶作剧池)。", - "direct (you forward the port)": "直连(P2P)", - "each with its own default). Fixedsys (VOTV) = game terminal pixel": "每个都有其默认值)。Fixedsys (VOTV) = 游戏终端像素", - "friends find you. Set a password below to keep strangers out.": "好友正是靠它找到你。想挡住陌生人,就设个密码。", - "give your friends your address and this password -- they type both into Direct Connect.": "把你的地址和这个密码发给好友,让他们在「直连」里分别填入。", - "host or host:port": "主机或主机:端口", - "in-game from the scoreboard once your friends have joined.": "好友加入后,可在游戏内用计分板操作。", - "instantly (drives the volume's own overlap with your pawn -- the native walk-in).": "立即(驱动音量与你的角色自身的重叠——等同真人走进去)。", - "multivoid.ini exists but could not be read (locked or failing).": "multivoid.ini 存在但读不出来(被占用或读取失败)。", - "multivoid.ini holds a zero byte or starts with a UTF-16 byte-order mark, as a file saved as UTF-16 (\"Unicode\") does. Save it again as UTF-8.": "multivoid.ini 是零字节文件,或以 UTF-16 字节序标记开头(即用 UTF-16 保存过)。请另存为 UTF-8。", - "multivoid.ini was checked against the known settings at launch.\nNothing was changed automatically -- broken values fall back to\ntheir defaults for this session, except the settings that refuse\ninstead (%s). The buttons below edit the file only when you click\nthem. This notice returns next launch while anything is still off.": "multivoid.ini 在启动时已与已知设置核对过。没有自动改动任何东西——出错的值本次会话回退到默认值,但那些「拒绝回退」的设置除外(%s)。下面的按钮只有在你点击时才编辑文件。只要还有问题,这条提示下次启动还会出现。", - "no IP": "无 IP", - "orange = your own ragdoll, cyan = a remote peer's mirror body.": "橙色 = 你自己的布娃娃,青色 = 远程同伴的镜像身体。", - "otherwise. Works without any router setup. Always listed -- hide it": "无需任何路由器设置。始终列出——想隐藏它", - "say:": "说:", - "server name": "服务器名称", - "sessions (ui.chat.peer_actions).": "会话(ui.chat.peer_actions)", - "strangers out.": "挡住陌生人。", - "the registry remembers every player who ever joined this host.": "名单会记住每个曾加入过这个房主的人。", - "the server browser or Direct Connect. Not sure? Use AUTO.": "服务器浏览器或「直连」。不确定?用自动就行。", - "they type both into Direct Connect.": "在「直连」里分别填入这两项。", - "would give out an invite link.": "就像发邀请链接一样。", - "your name": "你的名字", - "MULTIPLAYER###coop_browser": "多人游戏###coop_browser", - "Select a server": "选择一个服务器", - "server": "个服务器", - "servers": "个服务器", - "updated ": "更新于 ", - "just now": "刚刚", - "Your name: ": "你的名字:", - "Showing the ": "正在显示 ", - "server list.": "服务器列表。", - "Back": "返回", - "s ago": "秒前", - "Host": "房主" -} From 6edef335b14c6984b1d2d5a84e7590a32c7d0dce Mon Sep 17 00:00:00 2001 From: pelmentor Date: Sat, 10 Oct 2026 07:21:30 +0600 Subject: [PATCH 095/306] [l10n] a pseudo-locale, en_XA, generated with the template Every line of locale/en_XA.po is the English in [[ ]], its conversions intact, written by the template's own generator and checked current by its gate. Embedded like a real pack, it proves every surface looks its text up whatever a real pack covers: the l10n drill's translated arm runs on it, a screenshot shows any line without brackets as one that is not looked up, and the selftest's read of every embedded pack puts each English msgid through the C++ conversion check. Android ships the same instrument under the same tag; a player may pick it. Co-Authored-By: Claude Opus 5.5 --- .github/ci/l10n_pot.py | 47 +- src/votv-coop/locale/en_XA.po | 1502 +++++++++++++++++++++++++++++++++ 2 files changed, 1543 insertions(+), 6 deletions(-) create mode 100644 src/votv-coop/locale/en_XA.po diff --git a/.github/ci/l10n_pot.py b/.github/ci/l10n_pot.py index 5cccf200d..10b59aa85 100644 --- a/.github/ci/l10n_pot.py +++ b/.github/ci/l10n_pot.py @@ -13,8 +13,8 @@ (a drawer's `T(entry.name)`) is not extracted: its literal was marked where it is defined. Usage: - l10n_pot.py writes the template - l10n_pot.py --check the gate: exits 1 on a stale template or on any rule below + l10n_pot.py writes the template and the pseudo-locale (locale/en_XA.po) + l10n_pot.py --check the gate: exits 1 on a stale template or pseudo-locale, or on any rule below The gate's rules (the translation layer's design, WP-4): 1. an extraction call or marker with a literal argument in a file outside the allowlist; @@ -34,6 +34,11 @@ ROOT = pathlib.Path(__file__).resolve().parents[2] MOD = ROOT / "src" / "votv-coop" TEMPLATE = MOD / "locale" / "multivoid.pot" +# The pseudo-locale: every line the English in [[ ]], its conversions intact. Embedded like a real pack, it is how a +# run proves every surface looks its text up -- the l10n drill's translated arm, a screenshot where a line without +# brackets is one that is not -- whatever a real pack covers, and the selftest's read of every embedded pack puts each +# English msgid through the C++ conversion check. Android's tag for the same instrument; a player may pick it. +PSEUDO = MOD / "locale" / "en_XA.po" IMGUI_H = MOD / "third_party" / "imgui" / "imgui.h" # The files that may hold player text (the design's D6), relative to src/votv-coop/. Edited with each call-site @@ -363,6 +368,32 @@ def render(entries): return "\n".join(out) + "\n" +def render_pseudo(entries): + out = ["# The pseudo-locale en_XA: every line the English in [[ ]]. Generated by .github/ci/l10n_pot.py with the", + "# template; never edited by hand. A test instrument: a line without brackets on screen is not looked up.", + 'msgid ""', 'msgstr ""', + '"Project-Id-Version: Multivoid\\n"', + '"Language: en_XA\\n"', + '"Content-Type: text/plain; charset=UTF-8\\n"', + '"Content-Transfer-Encoding: 8bit\\n"', + '"Plural-Forms: nplurals=2; plural=(n != 1);\\n"'] + for (ctx, msgid) in sorted(entries): + e = entries[(ctx, msgid)] + out.append("") + if "%" in msgid: + out.append("#, c-format") + if ctx: + out.append("msgctxt " + quote(ctx)) + out.append("msgid " + quote(msgid)) + if e["plural"] is not None: + out.append("msgid_plural " + quote(e["plural"])) + out.append("msgstr[0] " + quote("[[" + msgid + "]]")) + out.append("msgstr[1] " + quote("[[" + e["plural"] + "]]")) + else: + out.append("msgstr " + quote("[[" + msgid + "]]")) + return "\n".join(out) + "\n" + + def pack_msgids(path): """The (ctx, msgid) pairs a .po file defines, by a minimal scan; validating translations is the runtime's.""" text = path.read_text(encoding="utf-8", errors="replace") @@ -394,10 +425,12 @@ def unq(s): def main(): entries, problems = scan() text = render(entries) + pseudo = render_pseudo(entries) if "--check" in sys.argv[1:]: - have = TEMPLATE.read_text(encoding="utf-8").replace("\r\n", "\n") if TEMPLATE.exists() else "" - if have != text: - problems.append("%s is stale -- run .github/ci/l10n_pot.py" % TEMPLATE.relative_to(ROOT).as_posix()) + for path, want in ((TEMPLATE, text), (PSEUDO, pseudo)): + have = path.read_text(encoding="utf-8").replace("\r\n", "\n") if path.exists() else "" + if have != want: + problems.append("%s is stale -- run .github/ci/l10n_pot.py" % path.relative_to(ROOT).as_posix()) known = set(entries) for po in sorted((MOD / "locale").glob("*.po")): for pair in sorted(pack_msgids(po) - known): @@ -413,7 +446,9 @@ def main(): return 1 TEMPLATE.parent.mkdir(parents=True, exist_ok=True) TEMPLATE.write_text(text, encoding="utf-8", newline="\n") - print("l10n_pot: wrote %s (%d entries)" % (TEMPLATE.relative_to(ROOT).as_posix(), len(entries))) + PSEUDO.write_text(pseudo, encoding="utf-8", newline="\n") + print("l10n_pot: wrote %s and %s (%d entries)" % (TEMPLATE.relative_to(ROOT).as_posix(), + PSEUDO.relative_to(ROOT).as_posix(), len(entries))) return 0 diff --git a/src/votv-coop/locale/en_XA.po b/src/votv-coop/locale/en_XA.po new file mode 100644 index 000000000..50253b9bb --- /dev/null +++ b/src/votv-coop/locale/en_XA.po @@ -0,0 +1,1502 @@ +# The pseudo-locale en_XA: every line the English in [[ ]]. Generated by .github/ci/l10n_pot.py with the +# template; never edited by hand. A test instrument: a line without brackets on screen is not looked up. +msgid "" +msgstr "" +"Project-Id-Version: Multivoid\n" +"Language: en_XA\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#, c-format +msgid "%.0f cm" +msgstr "[[%.0f cm]]" + +#, c-format +msgid "%.1f / %.1f MB" +msgstr "[[%.1f / %.1f MB]]" + +#, c-format +msgid "%1$s (%2$llu KB)" +msgstr "[[%1$s (%2$llu KB)]]" + +#, c-format +msgid "%1$s (last %2$llu KB of %3$llu KB)" +msgstr "[[%1$s (last %2$llu KB of %3$llu KB)]]" + +#, c-format +msgid "%1$s -- UPDATE AVAILABLE: %2$s" +msgstr "[[%1$s -- UPDATE AVAILABLE: %2$s]]" + +#, c-format +msgid "%1$s -- left out: %2$s" +msgstr "[[%1$s -- left out: %2$s]]" + +#, c-format +msgid "%1$s = '%2$s' (%3$s) -- %4$s; not in use, %5$s overrides it." +msgstr "[[%1$s = '%2$s' (%3$s) -- %4$s; not in use, %5$s overrides it.]]" + +#, c-format +msgid "%1$s = '%2$s' (%3$s) -- %4$s; nothing it governs starts until it is fixed." +msgstr "[[%1$s = '%2$s' (%3$s) -- %4$s; nothing it governs starts until it is fixed.]]" + +#, c-format +msgid "%1$s = '%2$s' (%3$s) -- %4$s; the built-in default is used." +msgstr "[[%1$s = '%2$s' (%3$s) -- %4$s; the built-in default is used.]]" + +#, c-format +msgid "%1$s = '%2$s' -- not a known setting (typo, or a retired key)." +msgstr "[[%1$s = '%2$s' -- not a known setting (typo, or a retired key).]]" + +#, c-format +msgid "%1$s appears %2$llu time with different values; the FIRST line (line %3$d) wins. Pick which one to keep:" +msgid_plural "%1$s appears %2$llu times with different values; the FIRST line (line %3$d) wins. Pick which one to keep:" +msgstr[0] "[[%1$s appears %2$llu time with different values; the FIRST line (line %3$d) wins. Pick which one to keep:]]" +msgstr[1] "[[%1$s appears %2$llu times with different values; the FIRST line (line %3$d) wins. Pick which one to keep:]]" + +#, c-format +msgid "%1$s caught signal '%2$s'" +msgstr "[[%1$s caught signal '%2$s']]" + +#, c-format +msgid "%1$s changed skin to %2$s" +msgstr "[[%1$s changed skin to %2$s]]" + +#, c-format +msgid "%1$s could not order: an item was not in the host's store" +msgstr "[[%1$s could not order: an item was not in the host's store]]" + +#, c-format +msgid "%1$s could not order: the delivery could not be placed" +msgstr "[[%1$s could not order: the delivery could not be placed]]" + +#, c-format +msgid "%1$s could not order: the host could not read its store" +msgstr "[[%1$s could not order: the host could not read its store]]" + +#, c-format +msgid "%1$s could not order: the host refused it" +msgstr "[[%1$s could not order: the host refused it]]" + +#, c-format +msgid "%1$s could not order: there were not enough credits" +msgstr "[[%1$s could not order: there were not enough credits]]" + +#, c-format +msgid "%1$s could not order: too many items in one order" +msgstr "[[%1$s could not order: too many items in one order]]" + +#, c-format +msgid "%1$s could not sell that" +msgstr "[[%1$s could not sell that]]" + +#, c-format +msgid "%1$s could not sell that: it was already sold" +msgstr "[[%1$s could not sell that: it was already sold]]" + +#, c-format +msgid "%1$s could not sell that: no coin gun exists in the host's world" +msgstr "[[%1$s could not sell that: no coin gun exists in the host's world]]" + +#, c-format +msgid "%1$s could not sell that: the host does not have it" +msgstr "[[%1$s could not sell that: the host does not have it]]" + +#, c-format +msgid "%1$s could not sell that: the host does not see you next to it" +msgstr "[[%1$s could not sell that: the host does not see you next to it]]" + +#, c-format +msgid "%1$s could not sell that: the host hit an internal error" +msgstr "[[%1$s could not sell that: the host hit an internal error]]" + +#, c-format +msgid "%1$s could not sell that: the host's store will not take it" +msgstr "[[%1$s could not sell that: the host's store will not take it]]" + +#, c-format +msgid "%1$s deleted an email: %2$s" +msgstr "[[%1$s deleted an email: %2$s]]" + +#, c-format +msgid "%1$s is connecting to the game..." +msgstr "[[%1$s is connecting to the game...]]" + +#, c-format +msgid "%1$s is using %2$s" +msgstr "[[%1$s is using %2$s]]" + +#, c-format +msgid "%1$s joined the game" +msgstr "[[%1$s joined the game]]" + +#, c-format +msgid "%1$s left the game" +msgstr "[[%1$s left the game]]" + +#, c-format +msgid "%1$s sold it for %2$lld point (the host's price)" +msgid_plural "%1$s sold it for %2$lld points (the host's price)" +msgstr[0] "[[%1$s sold it for %2$lld point (the host's price)]]" +msgstr[1] "[[%1$s sold it for %2$lld points (the host's price)]]" + +#, c-format +msgid "%1$s was turned away: Game version mismatch: server plays VOTV %2$s, client has VOTV %3$s. [%4$s]" +msgstr "[[%1$s was turned away: Game version mismatch: server plays VOTV %2$s, client has VOTV %3$s. [%4$s]]]" + +#, c-format +msgid "%1$s was turned away: malformed join (version field missing) [%2$s]" +msgstr "[[%1$s was turned away: malformed join (version field missing) [%2$s]]]" + +#, c-format +msgid "%1$s was turned away: official build %2$s [%3$s]" +msgstr "[[%1$s was turned away: official build %2$s [%3$s]]]" + +#, c-format +msgid "%1$s was turned away: unofficial build %2$s [%3$s]" +msgstr "[[%1$s was turned away: unofficial build %2$s [%3$s]]]" + +#, c-format +msgid "%1$u/%2$u players sleeping" +msgstr "[[%1$u/%2$u players sleeping]]" + +#, c-format +msgid "%d LAN" +msgstr "[[%d LAN]]" + +#, c-format +msgid "%d direct" +msgstr "[[%d direct]]" + +#, c-format +msgid "%d online" +msgstr "[[%d online]]" + +#, c-format +msgid "%d relayed" +msgstr "[[%d relayed]]" + +#, c-format +msgid "%llu rule in force differs from the rules saved with this world." +msgid_plural "%llu rules in force differ from the rules saved with this world." +msgstr[0] "[[%llu rule in force differs from the rules saved with this world.]]" +msgstr[1] "[[%llu rules in force differ from the rules saved with this world.]]" + +#, c-format +msgid "%llu s in this step" +msgstr "[[%llu s in this step]]" + +#, c-format +msgid "%llu server" +msgid_plural "%llu servers" +msgstr[0] "[[%llu server]]" +msgstr[1] "[[%llu servers]]" + +#, c-format +msgid "%s (without passwords)" +msgstr "[[%s (without passwords)]]" + +#, c-format +msgid "%u / %u objects" +msgstr "[[%u / %u objects]]" + +msgid "(hidden -- friends join by invite/IP)" +msgstr "[[(hidden -- friends join by invite/IP)]]" + +msgid "(locked)" +msgstr "[[(locked)]]" + +msgid "(no description)" +msgstr "[[(no description)]]" + +msgid "(no nick)" +msgstr "[[(no nick)]]" + +msgid "(no preview)" +msgstr "[[(no preview)]]" + +msgid "(others can find your game)" +msgstr "[[(others can find your game)]]" + +msgid "(system default)" +msgstr "[[(system default)]]" + +msgid "(unnamed)" +msgstr "[[(unnamed)]]" + +msgid "(you)" +msgstr "[[(you)]]" + +msgid "(your server)" +msgstr "[[(your server)]]" + +msgid "" +"A compact live panel in the top-right corner: receive/send rate right\n" +"now (wire-level, includes protocol overhead), total downloaded/uploaded\n" +"this session, packets/s, peers + ping, and a 60 s rate graph.\n" +"Works for host and clients. Saved to multivoid.ini (ui.netstats)." +msgstr "" +"[[A compact live panel in the top-right corner: receive/send rate right\n" +"now (wire-level, includes protocol overhead), total downloaded/uploaded\n" +"this session, packets/s, peers + ping, and a 60 s rate graph.\n" +"Works for host and clients. Saved to multivoid.ini (ui.netstats).]]" + +msgid "A new game can take a moment to load" +msgstr "[[A new game can take a moment to load]]" + +msgid "A newer connection with your identity replaced this one." +msgstr "[[A newer connection with your identity replaced this one.]]" + +msgid "A pak that carries no skin (another mod's) is dropped from this list once the game has been asked -- it is not a skin." +msgstr "[[A pak that carries no skin (another mod's) is dropped from this list once the game has been asked -- it is not a skin.]]" + +msgid "A player" +msgstr "[[A player]]" + +msgid "A proof arrived before a hello." +msgstr "[[A proof arrived before a hello.]]" + +msgid "A second hello arrived on one connection." +msgstr "[[A second hello arrived on one connection.]]" + +msgid "Actions" +msgstr "[[Actions]]" + +msgid "Activation" +msgstr "[[Activation]]" + +msgid "Administration" +msgstr "[[Administration]]" + +msgid "Allow other builds" +msgstr "[[Allow other builds]]" + +msgid "Also refuse their address" +msgstr "[[Also refuse their address]]" + +msgid "Also refuses anyone connecting from the same address -- people who share their router or their provider's address are refused too. It only works on a direct connection: a player who comes through a relay is refused by their identity alone." +msgstr "[[Also refuses anyone connecting from the same address -- people who share their router or their provider's address are refused too. It only works on a direct connection: a player who comes through a relay is refused by their identity alone.]]" + +msgid "As a client you see only what the host allows you." +msgstr "[[As a client you see only what the host allows you.]]" + +msgid "Ban (permanent)" +msgstr "[[Ban (permanent)]]" + +msgid "Ban player" +msgstr "[[Ban player]]" + +msgid "Banned" +msgstr "[[Banned]]" + +msgid "Body skin not saved" +msgstr "[[Body skin not saved]]" + +msgid "COULD NOT CONNECT" +msgstr "[[COULD NOT CONNECT]]" + +#, c-format +msgid "Cannot reach the %s server list. Check your connection." +msgstr "[[Cannot reach the %s server list. Check your connection.]]" + +#, c-format +msgid "Cannot reach the %s server list. Try another list above, or check your connection." +msgstr "[[Cannot reach the %s server list. Try another list above, or check your connection.]]" + +msgid "Change name" +msgstr "[[Change name]]" + +msgid "Chat" +msgstr "[[Chat]]" + +msgid "Click on the name of the rule to see its description." +msgstr "[[Click on the name of the rule to see its description.]]" + +msgid "Click to mute your mic" +msgstr "[[Click to mute your mic]]" + +#, c-format +msgid "Code %s -- include it in a bug report" +msgstr "[[Code %s -- include it in a bug report]]" + +msgid "" +"Colors your nick everywhere it shows -- nameplate, chat, player list --\n" +"on every peer's screen. Synced live and to late joiners; persists." +msgstr "" +"[[Colors your nick everywhere it shows -- nameplate, chat, player list --\n" +"on every peer's screen. Synced live and to late joiners; persists.]]" + +msgid "Confirm ban" +msgstr "[[Confirm ban]]" + +#, c-format +msgid "Connecting to %1$s's game..." +msgstr "[[Connecting to %1$s's game...]]" + +#, c-format +msgid "Connecting to %s" +msgstr "[[Connecting to %s]]" + +#, c-format +msgid "Connection: %s" +msgstr "[[Connection: %s]]" + +msgid "Console" +msgstr "[[Console]]" + +msgid "Contact (optional, e.g. a Discord name)" +msgstr "[[Contact (optional, e.g. a Discord name)]]" + +msgid "Cosmetics" +msgstr "[[Cosmetics]]" + +msgid "Could not compute the password proof on this machine." +msgstr "[[Could not compute the password proof on this machine.]]" + +msgid "Could not connect to that address -- check it, or another action is already in flight." +msgstr "[[Could not connect to that address -- check it, or another action is already in flight.]]" + +msgid "Could not find the log." +msgstr "[[Could not find the log.]]" + +msgid "Could not reach the server list." +msgstr "[[Could not reach the server list.]]" + +msgid "Could not read the log." +msgstr "[[Could not read the log.]]" + +msgid "Could not rewrite multivoid.ini (it is missing, or the write failed) -- nothing was changed." +msgstr "[[Could not rewrite multivoid.ini (it is missing, or the write failed) -- nothing was changed.]]" + +msgid "Could not rewrite multivoid.ini (locked or failing) -- nothing was changed. Close whatever holds the file and press Tidy up again." +msgstr "[[Could not rewrite multivoid.ini (locked or failing) -- nothing was changed. Close whatever holds the file and press Tidy up again.]]" + +msgid "Could not rewrite multivoid.ini: it holds a zero byte or a UTF-16 byte-order mark -- nothing was changed. Save it again as UTF-8 and press Tidy up again." +msgstr "[[Could not rewrite multivoid.ini: it holds a zero byte or a UTF-16 byte-order mark -- nothing was changed. Save it again as UTF-8 and press Tidy up again.]]" + +msgid "Could not send the command." +msgstr "[[Could not send the command.]]" + +msgid "Could not send the identity proof to the host." +msgstr "[[Could not send the identity proof to the host.]]" + +msgid "Could not start that connection -- another action is already in flight." +msgstr "[[Could not start that connection -- another action is already in flight.]]" + +msgid "Could not start the connection." +msgstr "[[Could not start the connection.]]" + +msgid "Could not start the identity exchange with this host." +msgstr "[[Could not start the identity exchange with this host.]]" + +msgid "Could not start the report." +msgstr "[[Could not start the report.]]" + +msgid "Could not write the report file." +msgstr "[[Could not write the report file.]]" + +msgid "Custom nickname color" +msgstr "[[Custom nickname color]]" + +msgid "DIRECT" +msgstr "[[DIRECT]]" + +msgid "DISCONNECTED" +msgstr "[[DISCONNECTED]]" + +msgid "Developer tools are hidden. Set [dev] devkeys=1 in multivoid.ini to show them." +msgstr "[[Developer tools are hidden. Set [dev] devkeys=1 in multivoid.ini to show them.]]" + +msgid "" +"Device changes apply immediately (the voice engine\n" +"reopens on the game's next tick)." +msgstr "" +"[[Device changes apply immediately (the voice engine\n" +"reopens on the game's next tick).]]" + +msgid "Devices" +msgstr "[[Devices]]" + +msgid "Direct" +msgstr "[[Direct]]" + +msgid "Direct connect" +msgstr "[[Direct connect]]" + +msgid "Disconnected now and refused whenever they rejoin." +msgstr "[[Disconnected now and refused whenever they rejoin.]]" + +#, c-format +msgid "Down: %1$s now, %2$s total, %3$llu packet" +msgid_plural "Down: %1$s now, %2$s total, %3$llu packets" +msgstr[0] "[[Down: %1$s now, %2$s total, %3$llu packet]]" +msgstr[1] "[[Down: %1$s now, %2$s total, %3$llu packets]]" + +msgid "Downloading the host's world" +msgstr "[[Downloading the host's world]]" + +msgid "" +"Dr. Kel\n" +"(default)" +msgstr "" +"[[Dr. Kel\n" +"(default)]]" + +msgid "Duplicate settings with different values" +msgstr "[[Duplicate settings with different values]]" + +msgid "Each surface picks its own family; applies on the next frame." +msgstr "[[Each surface picks its own family; applies on the next frame.]]" + +msgid "Everyone in the registry is online." +msgstr "[[Everyone in the registry is online.]]" + +msgid "Everyone is asleep -- the night passes..." +msgstr "[[Everyone is asleep -- the night passes...]]" + +msgid "Everything scales with the screen automatically; this is your extra zoom." +msgstr "[[Everything scales with the screen automatically; this is your extra zoom.]]" + +msgid "Exchanging identities with the host" +msgstr "[[Exchanging identities with the host]]" + +msgid "Files that will be included" +msgstr "[[Files that will be included]]" + +#, c-format +msgid "Finding a route to %s" +msgstr "[[Finding a route to %s]]" + +msgid "Gamemode:" +msgstr "[[Gamemode:]]" + +msgid "Good morning -- everyone is rested." +msgstr "[[Good morning -- everyone is rested.]]" + +#, c-format +msgid "Graphics API: %s" +msgstr "[[Graphics API: %s]]" + +msgid "HOST" +msgstr "[[HOST]]" + +#, c-format +msgid "" +"Hold the key to talk. Change the key via %s\n" +"in multivoid.ini (single letter or a virtual-key number)." +msgstr "" +"[[Hold the key to talk. Change the key via %s\n" +"in multivoid.ini (single letter or a virtual-key number).]]" + +msgid "" +"Host administration. Bans follow the player and survive restarts;\n" +"the registry remembers every player who ever joined this host." +msgstr "" +"[[Host administration. Bans follow the player and survive restarts;\n" +"the registry remembers every player who ever joined this host.]]" + +msgid "Host game" +msgstr "[[Host game]]" + +msgid "ID" +msgstr "[[ID]]" + +msgid "Interface" +msgstr "[[Interface]]" + +#, c-format +msgid "Joined %1$s's game" +msgstr "[[Joined %1$s's game]]" + +#, c-format +msgid "Joining %s" +msgstr "[[Joining %s]]" + +#, c-format +msgid "Keep line %d: %s" +msgstr "[[Keep line %d: %s]]" + +msgid "LAN" +msgstr "[[LAN]]" + +msgid "Last seen" +msgstr "[[Last seen]]" + +#, c-format +msgid "Last seen: %ds ago" +msgstr "[[Last seen: %ds ago]]" + +msgid "Left the session." +msgstr "[[Left the session.]]" + +msgid "Let players whose Multivoid build differs from this server's join -- an unofficial build when this server runs the official one, the official build when this server runs a modified one. Applies to the next joins; players already in stay." +msgstr "[[Let players whose Multivoid build differs from this server's join -- an unofficial build when this server runs the official one, the official build when this server runs a modified one. Applies to the next joins; players already in stay.]]" + +msgid "Levels" +msgstr "[[Levels]]" + +msgid "Link" +msgstr "[[Link]]" + +msgid "Loading the world" +msgstr "[[Loading the world]]" + +#, c-format +msgid "Looking up %s" +msgstr "[[Looking up %s]]" + +msgid "MULTIPLAYER" +msgstr "[[MULTIPLAYER]]" + +msgid "MULTIVOID SETTINGS CHECK" +msgstr "[[MULTIVOID SETTINGS CHECK]]" + +msgid "Maximum voice audibility distance in centimeters (4800 = 48 m); 0 = unlimited (a whisper stays local); the host's setting in a session. A value of 1..99 is a near-mute range, the host's to choose." +msgstr "[[Maximum voice audibility distance in centimeters (4800 = 48 m); 0 = unlimited (a whisper stays local); the host's setting in a session. A value of 1..99 is a near-mute range, the host's to choose.]]" + +msgid "Menu / panels" +msgstr "[[Menu / panels]]" + +msgid "Mic" +msgstr "[[Mic]]" + +msgid "Mic gain" +msgstr "[[Mic gain]]" + +msgid "Mic muted" +msgstr "[[Mic muted]]" + +msgid "Microphone" +msgstr "[[Microphone]]" + +msgid "Multivoid - Change name" +msgstr "[[Multivoid - Change name]]" + +msgid "Multivoid - Direct connect" +msgstr "[[Multivoid - Direct connect]]" + +msgid "Multivoid - Password" +msgstr "[[Multivoid - Password]]" + +msgid "Multivoid - Server Browser" +msgstr "[[Multivoid - Server Browser]]" + +msgid "Mute for me" +msgstr "[[Mute for me]]" + +msgid "Mute my microphone" +msgstr "[[Mute my microphone]]" + +msgid "Muted for you (click for volume)" +msgstr "[[Muted for you (click for volume)]]" + +#, c-format +msgid "NET %1$llu peer %2$d ms" +msgid_plural "NET %1$llu peers %2$d ms" +msgstr[0] "[[NET %1$llu peer %2$d ms]]" +msgstr[1] "[[NET %1$llu peers %2$d ms]]" + +#, c-format +msgid "NET %llu peer" +msgid_plural "NET %llu peers" +msgstr[0] "[[NET %llu peer]]" +msgstr[1] "[[NET %llu peers]]" + +msgid "NET offline" +msgstr "[[NET offline]]" + +msgid "Nameplate" +msgstr "[[Nameplate]]" + +msgid "Nameplate: hidden from other players" +msgstr "[[Nameplate: hidden from other players]]" + +msgid "Nameplate: shown to other players" +msgstr "[[Nameplate: shown to other players]]" + +msgid "Nameplates" +msgstr "[[Nameplates]]" + +msgid "Net stats" +msgstr "[[Net stats]]" + +msgid "Network" +msgstr "[[Network]]" + +msgid "Nickname color" +msgstr "[[Nickname color]]" + +msgid "Nickname color: applied (synced to other players)" +msgstr "[[Nickname color: applied (synced to other players)]]" + +msgid "Nickname color: reset to default" +msgstr "[[Nickname color: reset to default]]" + +msgid "No active session." +msgstr "[[No active session.]]" + +msgid "No answer from the host." +msgstr "[[No answer from the host.]]" + +msgid "No banned players." +msgstr "[[No banned players.]]" + +msgid "No microphone -- voice is receive-only." +msgstr "[[No microphone -- voice is receive-only.]]" + +msgid "No output device -- you can't hear voice." +msgstr "[[No output device -- you can't hear voice.]]" + +msgid "No players." +msgstr "[[No players.]]" + +msgid "No remote players connected." +msgstr "[[No remote players connected.]]" + +msgid "No route to the host through its firewall or router." +msgstr "[[No route to the host through its firewall or router.]]" + +msgid "No tools here yet -- coming soon." +msgstr "[[No tools here yet -- coming soon.]]" + +msgid "Nobody in the registry yet (players are recorded when they join)." +msgstr "[[Nobody in the registry yet (players are recorded when they join).]]" + +msgid "" +"Off = your floating name/health bar disappears on every peer's\n" +"screen -- synced live and to late joiners; persists across sessions." +msgstr "" +"[[Off = your floating name/health bar disappears on every peer's\n" +"screen -- synced live and to late joiners; persists across sessions.]]" + +msgid "Off by default; persists across sessions." +msgstr "[[Off by default; persists across sessions.]]" + +msgid "Offline (seen before)" +msgstr "[[Offline (seen before)]]" + +msgid "Online" +msgstr "[[Online]]" + +msgid "Other" +msgstr "[[Other]]" + +msgid "Output" +msgstr "[[Output]]" + +msgid "Overlay fonts (per surface):" +msgstr "[[Overlay fonts (per surface):]]" + +msgid "PLAYERS" +msgstr "[[PLAYERS]]" + +msgid "Password (leave empty if the server has none)" +msgstr "[[Password (leave empty if the server has none)]]" + +msgid "Peer action notifications" +msgstr "[[Peer action notifications]]" + +msgid "Peers WITHOUT that pak see the default kel body instead." +msgstr "[[Peers WITHOUT that pak see the default kel body instead.]]" + +#, c-format +msgid "Peers: %1$d, worst ping %2$d ms" +msgstr "[[Peers: %1$d, worst ping %2$d ms]]" + +#, c-format +msgid "Peers: %d" +msgstr "[[Peers: %d]]" + +#, c-format +msgid "Permanently ban %s?" +msgstr "[[Permanently ban %s?]]" + +msgid "Pick a server from the list first." +msgstr "[[Pick a server from the list first.]]" + +msgid "Ping" +msgstr "[[Ping]]" + +msgid "Player" +msgstr "[[Player]]" + +#, c-format +msgid "" +"Player id %1$s\n" +"Address %2$s" +msgstr "" +"[[Player id %1$s\n" +"Address %2$s]]" + +#, c-format +msgid "Player id %s" +msgstr "[[Player id %s]]" + +msgid "Players" +msgstr "[[Players]]" + +#, c-format +msgid "Players: %d/%d" +msgstr "[[Players: %d/%d]]" + +msgid "Please describe what happened (at least 20 characters)." +msgstr "[[Please describe what happened (at least 20 characters).]]" + +msgid "Preview tile = .png/.bmp beside the pak." +msgstr "[[Preview tile = .png/.bmp beside the pak.]]" + +#, c-format +msgid "Push-to-talk (key: %s)" +msgstr "[[Push-to-talk (key: %s)]]" + +msgid "RELAY" +msgstr "[[RELAY]]" + +msgid "Read-only. The host's save sets the rules for everyone." +msgstr "[[Read-only. The host's save sets the rules for everyone.]]" + +msgid "Reading rules..." +msgstr "[[Reading rules...]]" + +msgid "Reason" +msgstr "[[Reason]]" + +msgid "Receiving world from the host" +msgstr "[[Receiving world from the host]]" + +msgid "Refresh list" +msgstr "[[Refresh list]]" + +msgid "Refreshing the server list..." +msgstr "[[Refreshing the server list...]]" + +msgid "Refused whenever they rejoin." +msgstr "[[Refused whenever they rejoin.]]" + +msgid "Relay" +msgstr "[[Relay]]" + +msgid "Release toast" +msgstr "[[Release toast]]" + +msgid "Remote player" +msgstr "[[Remote player]]" + +msgid "Report" +msgstr "[[Report]]" + +msgid "Report a bug" +msgstr "[[Report a bug]]" + +msgid "Rescan devices" +msgstr "[[Rescan devices]]" + +#, c-format +msgid "" +"Rewrites multivoid.ini in the standard layout: [net] first, [dev]\n" +"last, each setting under its section, exact-duplicate lines merged.\n" +"Unknown settings and invalid values are commented out (kept in the\n" +"file, just disabled) -- that resolves their warnings above. An\n" +"invalid value of a setting that refuses instead (%s) is kept as\n" +"it is: disabling it would switch that setting to its default.\n" +"Conflicting duplicates are NEVER auto-resolved -- use the keep-line\n" +"buttons above. Your comments travel with their settings." +msgstr "" +"[[Rewrites multivoid.ini in the standard layout: [net] first, [dev]\n" +"last, each setting under its section, exact-duplicate lines merged.\n" +"Unknown settings and invalid values are commented out (kept in the\n" +"file, just disabled) -- that resolves their warnings above. An\n" +"invalid value of a setting that refuses instead (%s) is kept as\n" +"it is: disabling it would switch that setting to its default.\n" +"Conflicting duplicates are NEVER auto-resolved -- use the keep-line\n" +"buttons above. Your comments travel with their settings.]]" + +msgid "Rule" +msgstr "[[Rule]]" + +msgid "Rules" +msgstr "[[Rules]]" + +msgid "Save report" +msgstr "[[Save report]]" + +msgid "" +"Saved to multivoid.ini (ui.font.menu/chat/net/nameplate/toast,\n" +"each with its own default). Fixedsys (VOTV) = game terminal pixel\n" +"font; JetBrains/Cascadia monospace; Roboto proportional." +msgstr "" +"[[Saved to multivoid.ini (ui.font.menu/chat/net/nameplate/toast,\n" +"each with its own default). Fixedsys (VOTV) = game terminal pixel\n" +"font; JetBrains/Cascadia monospace; Roboto proportional.]]" + +#, c-format +msgid "Saved: %1$s (%2$llu KB)" +msgstr "[[Saved: %1$s (%2$llu KB)]]" + +msgid "Saves a report file on this PC. Nothing is sent." +msgstr "[[Saves a report file on this PC. Nothing is sent.]]" + +msgid "Saving the report..." +msgstr "[[Saving the report...]]" + +msgid "" +"Scales the WHOLE overlay (menus, chat, nameplates) on top of the\n" +"automatic resolution scale. Applies when you release the slider\n" +"(the fonts re-bake). Saved to multivoid.ini (ui.scale)." +msgstr "" +"[[Scales the WHOLE overlay (menus, chat, nameplates) on top of the\n" +"automatic resolution scale. Applies when you release the slider\n" +"(the fonts re-bake). Saved to multivoid.ini (ui.scale).]]" + +msgid "Select a category on the left." +msgstr "[[Select a category on the left.]]" + +msgid "Select a server" +msgstr "[[Select a server]]" + +msgid "Server address" +msgstr "[[Server address]]" + +msgid "Server info:" +msgstr "[[Server info:]]" + +msgid "Server password" +msgstr "[[Server password]]" + +#, c-format +msgid "Server setting changed: %1$s is now %2$s." +msgstr "[[Server setting changed: %1$s is now %2$s.]]" + +msgid "Server settings" +msgstr "[[Server settings]]" + +msgid "Session" +msgstr "[[Session]]" + +msgid "" +"Show a chat line when another player does a shared action\n" +"(e.g. deletes an email). Local preference; persists across\n" +"sessions (ui.chat.peer_actions)." +msgstr "" +"[[Show a chat line when another player does a shared action\n" +"(e.g. deletes an email). Local preference; persists across\n" +"sessions (ui.chat.peer_actions).]]" + +msgid "Show in folder" +msgstr "[[Show in folder]]" + +msgid "Show in server browser" +msgstr "[[Show in server browser]]" + +msgid "Show my nameplate to other players" +msgstr "[[Show my nameplate to other players]]" + +msgid "Show network stats overlay" +msgstr "[[Show network stats overlay]]" + +#, c-format +msgid "Showing the %s server list." +msgstr "[[Showing the %s server list.]]" + +#, c-format +msgid "Skin '%s' is not installed here -- keeping your current one" +msgstr "[[Skin '%s' is not installed here -- keeping your current one]]" + +#, c-format +msgid "Skin: %s" +msgstr "[[Skin: %s]]" + +msgid "Skins" +msgstr "[[Skins]]" + +msgid "Skins come from converter .pak files under Content/Paks/LogicMods (any subfolder). One pak may carry several skins -- the four scientists ship together in scientists.pak." +msgstr "[[Skins come from converter .pak files under Content/Paks/LogicMods (any subfolder). One pak may carry several skins -- the four scientists ship together in scientists.pak.]]" + +msgid "Sleep interrupted -- everyone wakes up." +msgstr "[[Sleep interrupted -- everyone wakes up.]]" + +msgid "Something was sent before the identity was proved." +msgstr "[[Something was sent before the identity was proved.]]" + +msgid "" +"Speech louder than this transmits. Watch the mic\n" +"meter: set the slider just above your room's noise." +msgstr "" +"[[Speech louder than this transmits. Watch the mic\n" +"meter: set the slider just above your room's noise.]]" + +msgid "Starting your server" +msgstr "[[Starting your server]]" + +#, c-format +msgid "Starting your server -- loading %s" +msgstr "[[Starting your server -- loading %s]]" + +msgid "Stats" +msgstr "[[Stats]]" + +msgid "Takes effect at the next session." +msgstr "[[Takes effect at the next session.]]" + +msgid "Talking" +msgstr "[[Talking]]" + +msgid "Teleport to me" +msgstr "[[Teleport to me]]" + +msgid "That line is too long." +msgstr "[[That line is too long.]]" + +msgid "That text is too long." +msgstr "[[That text is too long.]]" + +msgid "That's your own server -- you're already hosting it." +msgstr "[[That's your own server -- you're already hosting it.]]" + +msgid "The ban file has entries this version cannot read, so bans made now last until the server stops. See the log." +msgstr "[[The ban file has entries this version cannot read, so bans made now last until the server stops. See the log.]]" + +msgid "The connection fell too far behind and was closed." +msgstr "[[The connection fell too far behind and was closed.]]" + +msgid "The connection was lost." +msgstr "[[The connection was lost.]]" + +msgid "The download of the host's world stopped part way through." +msgstr "[[The download of the host's world stopped part way through.]]" + +msgid "The game is closing." +msgstr "[[The game is closing.]]" + +msgid "The game is shutting down." +msgstr "[[The game is shutting down.]]" + +msgid "The host banned you." +msgstr "[[The host banned you.]]" + +msgid "The host closed the connection." +msgstr "[[The host closed the connection.]]" + +msgid "The host closed the connection: it fell too far behind." +msgstr "[[The host closed the connection: it fell too far behind.]]" + +msgid "The host could not accept the connection." +msgstr "[[The host could not accept the connection.]]" + +msgid "The host could not check the password." +msgstr "[[The host could not check the password.]]" + +msgid "The host could not prove its identity." +msgstr "[[The host could not prove its identity.]]" + +msgid "The host could not read your hello." +msgstr "[[The host could not read your hello.]]" + +msgid "The host could not read your proof." +msgstr "[[The host could not read your proof.]]" + +msgid "The host could not send its challenge." +msgstr "[[The host could not send its challenge.]]" + +msgid "The host did not answer through the signaling server. It may be offline, or reconnecting -- try again in a minute." +msgstr "[[The host did not answer through the signaling server. It may be offline, or reconnecting -- try again in a minute.]]" + +msgid "The host ended the session." +msgstr "[[The host ended the session.]]" + +msgid "The host has no randomness to challenge with." +msgstr "[[The host has no randomness to challenge with.]]" + +msgid "The host is settling its world" +msgstr "[[The host is settling its world]]" + +msgid "The host kicked you." +msgstr "[[The host kicked you.]]" + +msgid "The host never finished preparing its world for you." +msgstr "[[The host never finished preparing its world for you.]]" + +msgid "The host never sent you the world it owed." +msgstr "[[The host never sent you the world it owed.]]" + +msgid "The host never sent your inventory." +msgstr "[[The host never sent your inventory.]]" + +msgid "The host plays a different version of the game." +msgstr "[[The host plays a different version of the game.]]" + +msgid "The host received a malformed packet." +msgstr "[[The host received a malformed packet.]]" + +msgid "The host runs a newer build of the mod. Update to join." +msgstr "[[The host runs a newer build of the mod. Update to join.]]" + +msgid "The host runs an older build of the mod. They need to update." +msgstr "[[The host runs an older build of the mod. They need to update.]]" + +msgid "The host sent an identity challenge this build cannot read." +msgstr "[[The host sent an identity challenge this build cannot read.]]" + +msgid "The host was busy, and this connection was the slowest to prove itself." +msgstr "[[The host was busy, and this connection was the slowest to prove itself.]]" + +msgid "The host's world arrived damaged and could not be used." +msgstr "[[The host's world arrived damaged and could not be used.]]" + +msgid "The host's world would not load on this machine." +msgstr "[[The host's world would not load on this machine.]]" + +msgid "The identity proof did not arrive in time." +msgstr "[[The identity proof did not arrive in time.]]" + +msgid "The join failed before it started. The log has the error." +msgstr "[[The join failed before it started. The log has the error.]]" + +msgid "The report could not be made." +msgstr "[[The report could not be made.]]" + +msgid "The server is full." +msgstr "[[The server is full.]]" + +msgid "The server list gave a bad address for this host." +msgstr "[[The server list gave a bad address for this host.]]" + +msgid "The signaling server could not reach the host." +msgstr "[[The signaling server could not reach the host.]]" + +msgid "The transport handshake with the host failed." +msgstr "[[The transport handshake with the host failed.]]" + +msgid "Their identity is not proved yet." +msgstr "[[Their identity is not proved yet.]]" + +msgid "This key holds your body skin -- if the winning value is not the one you expect, keep the other line." +msgstr "[[This key holds your body skin -- if the winning value is not the one you expect, keep the other line.]]" + +#, c-format +msgid "This launch runs on environment overrides and built-in defaults; the settings that refuse instead of falling back (%s) refuse what they govern until the file can be read." +msgstr "[[This launch runs on environment overrides and built-in defaults; the settings that refuse instead of falling back (%s) refuse what they govern until the file can be read.]]" + +msgid "This machine has no working connection to the signaling server, so the host could not be dialled." +msgstr "[[This machine has no working connection to the signaling server, so the host could not be dialled.]]" + +msgid "" +"This rule is achievement-locked!\n" +"Unlock this rule by getting following achievements:" +msgstr "" +"[[This rule is achievement-locked!\n" +"Unlock this rule by getting following achievements:]]" + +#, c-format +msgid "" +"This rule is day-locked!\n" +"Unlock this rule by playing past day %d" +msgstr "" +"[[This rule is day-locked!\n" +"Unlock this rule by playing past day %d]]" + +msgid "This server admits only the official Multivoid build. Install the official release (the whole zip)." +msgstr "[[This server admits only the official Multivoid build. Install the official release (the whole zip).]]" + +msgid "This server has no settings to change here." +msgstr "[[This server has no settings to change here.]]" + +msgid "This server needs a password, and nothing told this machine which host it was dialling." +msgstr "[[This server needs a password, and nothing told this machine which host it was dialling.]]" + +msgid "This server needs a password." +msgstr "[[This server needs a password.]]" + +msgid "This server runs a modified (unofficial) Multivoid build and admits only modified builds." +msgstr "[[This server runs a modified (unofficial) Multivoid build and admits only modified builds.]]" + +msgid "Threshold" +msgstr "[[Threshold]]" + +msgid "Threshold near 0 dB -- the mic will almost never trigger. Try -50 dB." +msgstr "[[Threshold near 0 dB -- the mic will almost never trigger. Try -50 dB.]]" + +#, c-format +msgid "Tidied: %d duplicate line(s) merged, %d setting(s) placed under their sections, %d unknown/invalid line(s) commented out. Anything still listed above needs a choice or can't be fixed from the file." +msgstr "[[Tidied: %d duplicate line(s) merged, %d setting(s) placed under their sections, %d unknown/invalid line(s) commented out. Anything still listed above needs a choice or can't be fixed from the file.]]" + +msgid "Tidy up multivoid.ini" +msgstr "[[Tidy up multivoid.ini]]" + +msgid "Too many connections from you in a short time. Try again shortly." +msgstr "[[Too many connections from you in a short time. Try again shortly.]]" + +msgid "Too many password attempts. Try again in ten minutes." +msgstr "[[Too many password attempts. Try again in ten minutes.]]" + +msgid "Type /help for the commands." +msgstr "[[Type /help for the commands.]]" + +msgid "Type a name first." +msgstr "[[Type a name first.]]" + +msgid "Type an address first -- host or host:port." +msgstr "[[Type an address first -- host or host:port.]]" + +msgid "Type the password this server was locked with." +msgstr "[[Type the password this server was locked with.]]" + +msgid "UI size" +msgstr "[[UI size]]" + +msgid "Unknown settings (nothing reads these)" +msgstr "[[Unknown settings (nothing reads these)]]" + +#, c-format +msgid "Up: %1$s now, %2$s total, %3$llu packet" +msgid_plural "Up: %1$s now, %2$s total, %3$llu packets" +msgstr[0] "[[Up: %1$s now, %2$s total, %3$llu packet]]" +msgstr[1] "[[Up: %1$s now, %2$s total, %3$llu packets]]" + +msgid "Update list" +msgstr "[[Update list]]" + +msgid "V: voice settings" +msgstr "[[V: voice settings]]" + +msgid "Value" +msgstr "[[Value]]" + +msgid "Values that could not be used" +msgstr "[[Values that could not be used]]" + +#, c-format +msgid "Version: %1$s%2$s" +msgstr "[[Version: %1$s%2$s]]" + +#, c-format +msgid "Version: %1$s%2$s -- built for a different game version" +msgstr "[[Version: %1$s%2$s -- built for a different game version]]" + +#, c-format +msgid "Version: %1$s%2$s -- the host must update" +msgstr "[[Version: %1$s%2$s -- the host must update]]" + +#, c-format +msgid "Version: %1$s%2$s -- you must update" +msgstr "[[Version: %1$s%2$s -- you must update]]" + +msgid "Voice activation" +msgstr "[[Voice activation]]" + +msgid "Voice chat" +msgstr "[[Voice chat]]" + +#, c-format +msgid "Voice chat is turned off (%s=0 in multivoid.ini). Set it to 1 and restart the game to use voice." +msgstr "[[Voice chat is turned off (%s=0 in multivoid.ini). Set it to 1 and restart the game to use voice.]]" + +msgid "Voice connected (click for volume)" +msgstr "[[Voice connected (click for volume)]]" + +msgid "Voice disconnected" +msgstr "[[Voice disconnected]]" + +msgid "Voice off" +msgstr "[[Voice off]]" + +msgid "Voice range" +msgstr "[[Voice range]]" + +#, c-format +msgid "Voice range: %s" +msgstr "[[Voice range: %s]]" + +#, c-format +msgid "Voice range: %s (set by the host)" +msgstr "[[Voice range: %s (set by the host)]]" + +#, c-format +msgid "Voice range: %s (your server's; multivoid.ini until the settings page)" +msgstr "[[Voice range: %s (your server's; multivoid.ini until the settings page)]]" + +msgid "Voice volume" +msgstr "[[Voice volume]]" + +#, c-format +msgid "Waiting for the %s list..." +msgstr "[[Waiting for the %s list...]]" + +msgid "Waiting for the host to capture its world" +msgstr "[[Waiting for the host to capture its world]]" + +msgid "Waiting for the host's world" +msgstr "[[Waiting for the host's world]]" + +msgid "What happened" +msgstr "[[What happened]]" + +msgid "What you expected" +msgstr "[[What you expected]]" + +msgid "When" +msgstr "[[When]]" + +msgid "Whispering" +msgstr "[[Whispering]]" + +msgid "World" +msgstr "[[World]]" + +msgid "World rules unavailable (still loading?)." +msgstr "[[World rules unavailable (still loading?).]]" + +#, c-format +msgid "World: %s" +msgstr "[[World: %s]]" + +msgid "Wrong password." +msgstr "[[Wrong password.]]" + +msgid "You are banned from this server." +msgstr "[[You are banned from this server.]]" + +#, c-format +msgid "Your body skin (%s) could not be saved to multivoid.ini this launch -- it is temporary for this session. Fix whatever keeps the file from being read or saved (a lock from an editor or antivirus, permissions, or a file saved as UTF-16); nothing is lost once a launch can save again." +msgstr "[[Your body skin (%s) could not be saved to multivoid.ini this launch -- it is temporary for this session. Fix whatever keeps the file from being read or saved (a lock from an editor or antivirus, permissions, or a file saved as UTF-16); nothing is lost once a launch can save again.]]" + +msgid "Your body skin -- what YOU see looking down and what OTHERS see. Saved to multivoid.ini; restored on rejoin." +msgstr "[[Your body skin -- what YOU see looking down and what OTHERS see. Saved to multivoid.ini; restored on rejoin.]]" + +msgid "Your connection presented no key identity." +msgstr "[[Your connection presented no key identity.]]" + +msgid "Your game loaded the world but never finished settling into it." +msgstr "[[Your game loaded the world but never finished settling into it.]]" + +msgid "Your identity changed during the exchange." +msgstr "[[Your identity changed during the exchange.]]" + +msgid "Your identity proof did not verify." +msgstr "[[Your identity proof did not verify.]]" + +msgid "Your mic is muted -- click to unmute" +msgstr "[[Your mic is muted -- click to unmute]]" + +msgid "Your mod build differs from the host's." +msgstr "[[Your mod build differs from the host's.]]" + +msgid "Your name" +msgstr "[[Your name]]" + +#, c-format +msgid "Your name: %s" +msgstr "[[Your name: %s]]" + +msgid "Your net.ice setting could not be read as all or relay. Set it to one of them in multivoid.ini, or make the file readable." +msgstr "[[Your net.ice setting could not be read as all or relay. Set it to one of them in multivoid.ini, or make the file readable.]]" + +msgid "Your net.ice=relay allows only a relayed connection, and this session has no relay server." +msgstr "[[Your net.ice=relay allows only a relayed connection, and this session has no relay server.]]" + +msgid "Your net.ice=relay keeps your address from other players, and a direct connection would show it to the host. Join a lobby that is not direct, or remove net.ice from multivoid.ini." +msgstr "[[Your net.ice=relay keeps your address from other players, and a direct connection would show it to the host. Join a lobby that is not direct, or remove net.ice from multivoid.ini.]]" + +#, c-format +msgid "Your player id %s" +msgstr "[[Your player id %s]]" + +msgid "banned" +msgstr "[[banned]]" + +msgid "could not be read" +msgstr "[[could not be read]]" + +#, c-format +msgid "current: %s" +msgstr "[[current: %s]]" + +msgid "direct" +msgstr "[[direct]]" + +msgid "host or host:port" +msgstr "[[host or host:port]]" + +msgid "multivoid.ini could not be read" +msgstr "[[multivoid.ini could not be read]]" + +msgid "multivoid.ini could not be read earlier this launch (a zero byte or a UTF-16 byte-order mark) and reads now. Settings read while it could not used their defaults; restart so every setting reads the file." +msgstr "[[multivoid.ini could not be read earlier this launch (a zero byte or a UTF-16 byte-order mark) and reads now. Settings read while it could not used their defaults; restart so every setting reads the file.]]" + +msgid "multivoid.ini could not be read earlier this launch (locked or failing) and reads now. Settings read while it could not used their defaults; restart so every setting reads the file." +msgstr "[[multivoid.ini could not be read earlier this launch (locked or failing) and reads now. Settings read while it could not used their defaults; restart so every setting reads the file.]]" + +msgid "multivoid.ini exists but could not be read (locked or failing)." +msgstr "[[multivoid.ini exists but could not be read (locked or failing).]]" + +msgid "multivoid.ini holds a zero byte or starts with a UTF-16 byte-order mark, as a file saved as UTF-16 (\"Unicode\") does. Save it again as UTF-8." +msgstr "[[multivoid.ini holds a zero byte or starts with a UTF-16 byte-order mark, as a file saved as UTF-16 (\"Unicode\") does. Save it again as UTF-8.]]" + +#, c-format +msgid "" +"multivoid.ini was checked against the known settings at launch.\n" +"Nothing was changed automatically -- broken values fall back to\n" +"their defaults for this session, except the settings that refuse\n" +"instead (%s). The buttons below edit the file only when you click\n" +"them. This notice returns next launch while anything is still off." +msgstr "" +"[[multivoid.ini was checked against the known settings at launch.\n" +"Nothing was changed automatically -- broken values fall back to\n" +"their defaults for this session, except the settings that refuse\n" +"instead (%s). The buttons below edit the file only when you click\n" +"them. This notice returns next launch while anything is still off.]]" + +msgid "n/a" +msgstr "[[n/a]]" + +msgid "not blocked" +msgstr "[[not blocked]]" + +msgid "not present" +msgstr "[[not present]]" + +#, c-format +msgid "now: %s" +msgstr "[[now: %s]]" + +msgid "offline" +msgstr "[[offline]]" + +msgid "other build" +msgstr "[[other build]]" + +msgid "p2p" +msgstr "[[p2p]]" + +msgid "password" +msgstr "[[password]]" + +#, c-format +msgid "pkt/s %1$.0f in %2$.0f out" +msgstr "[[pkt/s %1$.0f in %2$.0f out]]" + +msgid "reason (shown in the Banned list)" +msgstr "[[reason (shown in the Banned list)]]" + +msgid "starting up" +msgstr "[[starting up]]" + +msgid "the host" +msgstr "[[the host]]" + +msgid "this player" +msgstr "[[this player]]" + +msgid "type a command -- 'help'" +msgstr "[[type a command -- 'help']]" + +msgid "unknown" +msgstr "[[unknown]]" + +msgid "unlimited" +msgstr "[[unlimited]]" + +#, c-format +msgid "updated %ds ago" +msgstr "[[updated %ds ago]]" + +msgid "updated just now" +msgstr "[[updated just now]]" + +#, c-format +msgid "volume %.2fx" +msgstr "[[volume %.2fx]]" + +msgid "written by a build with another log format" +msgstr "[[written by a build with another log format]]" + +msgid "your name" +msgstr "[[your name]]" + +msgctxt "f1" +msgid "Apply" +msgstr "[[Apply]]" + +msgctxt "f1" +msgid "Ban" +msgstr "[[Ban]]" + +msgctxt "f1" +msgid "Ban..." +msgstr "[[Ban...]]" + +msgctxt "f1" +msgid "Cancel" +msgstr "[[Cancel]]" + +msgctxt "f1" +msgid "Kick" +msgstr "[[Kick]]" + +msgctxt "f1" +msgid "Off" +msgstr "[[Off]]" + +msgctxt "f1" +msgid "On" +msgstr "[[On]]" + +msgctxt "f1" +msgid "Refresh" +msgstr "[[Refresh]]" + +msgctxt "f1" +msgid "Reset" +msgstr "[[Reset]]" + +msgctxt "f1" +msgid "Teleport" +msgstr "[[Teleport]]" + +msgctxt "f1" +msgid "Unban" +msgstr "[[Unban]]" + +msgctxt "hud" +msgid "Ban" +msgstr "[[Ban]]" + +msgctxt "hud" +msgid "Cancel" +msgstr "[[Cancel]]" + +msgctxt "hud" +msgid "Dismiss" +msgstr "[[Dismiss]]" + +msgctxt "hud" +msgid "Kick" +msgstr "[[Kick]]" + +msgctxt "hud" +msgid "OK" +msgstr "[[OK]]" + +msgctxt "server_browser" +msgid "Back" +msgstr "[[Back]]" + +msgctxt "server_browser" +msgid "Connect" +msgstr "[[Connect]]" + +msgctxt "server_browser" +msgid "Join" +msgstr "[[Join]]" + +msgctxt "server_browser" +msgid "OK" +msgstr "[[OK]]" + +msgctxt "setting_value" +msgid "off" +msgstr "[[off]]" + +msgctxt "setting_value" +msgid "on" +msgstr "[[on]]" From 06e8a9348c1927b23a7072f542539c785acc4347 Mon Sep 17 00:00:00 2001 From: pelmentor Date: Sat, 10 Oct 2026 07:41:52 +0600 Subject: [PATCH 096/306] [l10n] the call sites' audit folds A translation may number its conversions only when its msgid does: an unnumbered msgid can reach the CRT's own printf as an ImGui value format, which has no argument numbers; the gate now requires every msgid of two or more conversions to be numbered, five were not. The chat input's prefix and hint are looked up. The feed's English accessor goes and its two logs print the codes they carry. The UTF-8 Push drops control bytes as the wide path does; the dead wide PushDelayed goes; one UTF-8-to-wide helper replaces four copies; two buffers reach R3's 256 bytes; a pane request clears the drawn selection. The pack follows the template, and the selftest requires both embedded packs. Co-Authored-By: Claude Opus 5.5 --- .github/ci/l10n_pot.py | 10 +++++-- docs/code-map.md | 7 +++-- src/votv-coop/include/coop/comms/chat_feed.h | 1 - .../include/coop/comms/peer_action_feed.h | 2 -- src/votv-coop/include/coop/dev/chat_drill.h | 4 +-- src/votv-coop/include/coop/text/utf8_codec.h | 2 ++ src/votv-coop/include/ui/fonts.h | 2 +- src/votv-coop/locale/en_XA.po | 30 +++++++++++-------- src/votv-coop/locale/multivoid.pot | 26 ++++++++++------ src/votv-coop/locale/zh_CN.po | 22 +++++++++----- src/votv-coop/src/coop/comms/chat_feed.cpp | 12 ++++---- .../src/coop/comms/peer_action_feed.cpp | 4 --- src/votv-coop/src/coop/dev/chat_drill.cpp | 2 +- src/votv-coop/src/coop/items/coingun_sync.cpp | 4 +-- src/votv-coop/src/coop/items/order_sync.cpp | 4 +-- src/votv-coop/src/coop/player/sleep_sync.cpp | 2 +- src/votv-coop/src/coop/text/utf8_codec.cpp | 4 +++ src/votv-coop/src/l10n/l10n_selftest.cpp | 15 ++++++++-- src/votv-coop/src/l10n/printf_check.cpp | 5 ++++ src/votv-coop/src/l10n/printf_check.h | 5 +++- .../src/ui/browser_input_screens.cpp | 19 +++++------- src/votv-coop/src/ui/chat_input.cpp | 6 ++-- src/votv-coop/src/ui/config_review_panel.cpp | 6 ++-- src/votv-coop/src/ui/dev_menu.cpp | 3 +- src/votv-coop/src/ui/loading_screen.cpp | 4 +-- .../src/ui/server_browser_actions.cpp | 9 ++---- .../src/ui/server_browser_native.cpp | 9 ++---- .../src/ui/server_browser_panels.cpp | 9 ++---- src/votv-coop/src/ui/server_browser_rows.cpp | 2 +- 29 files changed, 129 insertions(+), 101 deletions(-) diff --git a/.github/ci/l10n_pot.py b/.github/ci/l10n_pot.py index 10b59aa85..86d80986d 100644 --- a/.github/ci/l10n_pot.py +++ b/.github/ci/l10n_pot.py @@ -20,8 +20,8 @@ 1. an extraction call or marker with a literal argument in a file outside the allowlist; 2. an id-bearing ImGui widget (a function of imgui.h whose first parameter is label, name or str_id) whose first argument is an l10n::T family call rather than an l10n::Label; - 3. an English msgid with a '%' that the conversion grammar refuses, or a plural pair whose msgids differ in their - conversions; + 3. an English msgid with a '%' that the conversion grammar refuses, a msgid with two or more conversions that are + not numbered, or a plural pair whose msgids differ in their conversions; 4. an l10n::Fmt or l10n::FmtSpan call whose format is an l10n::T family call on literals and whose argument count differs from that msgid's conversions; 5. a shipped pack (locale/*.po) holding a msgid the template does not. @@ -64,6 +64,7 @@ "src/ui/admin_panel.cpp", "src/ui/browser_input_screens.cpp", "src/ui/bug_report_pane.cpp", + "src/ui/chat_input.cpp", "src/ui/config_review_panel.cpp", "src/ui/console.cpp", "src/ui/dev_menu.cpp", @@ -305,6 +306,11 @@ def scan(): a = signature(vals["id"]) if vals.get("plural") is not None and signature(vals["plural"]) != a: problems.append("%s: a plural pair whose msgids differ in their conversions" % where) + # A translation may number its conversions only if the msgid does; a msgid with two or + # more is numbered, so a translator can always reorder them. + for m in [vals["id"]] + ([vals["plural"]] if vals.get("plural") else []): + if len(signature(m)) >= 2 and not re.search(r"%[0-9]+\$", m): + problems.append("%s: msgid %r has two or more conversions and no argument numbers" % (where, m)) except ValueError as err: problems.append("%s: msgid %r refused by the conversion grammar: %s" % (where, vals["id"], err)) elif name.split("::")[-1] in widgets and name.startswith("ImGui::") and args: diff --git a/docs/code-map.md b/docs/code-map.md index 6d72bc7ec..368c2e83f 100644 --- a/docs/code-map.md +++ b/docs/code-map.md @@ -51,14 +51,15 @@ One folder, one concept, named after it. `src/votv-coop/src/` holds the implemen | the native UI kit | `native_screen`, `native_text_field`, `style`, `scale`, `link_format` | | in-game surfaces | `hud` (nameplates, chat, the event feed), `chat_input`, `chat_view`, `scoreboard`, `loading_screen`, `join_curtain`, `voice_icons` | | the F1 overlay | `imgui_overlay`, `overlay_backend` with `overlay_backend_dx11`, `overlay_backend_dx12`, `overlay_backend_dx12_capture`, `overlay_cursor`, `overlay_diag`, `overlay_test_arm`, `fonts`, `atlas_watch`, `input_focus` | -| overlay panels | `dev_menu`, `admin_panel`, `skins_panel`, `voice_panel`, `world_rules_panel`, `server_settings_pane`, `bug_report_pane`, `config_review_panel`, `net_stats_panel`, `console` | +| overlay panels | `dev_menu` (its developer panes in `dev_panes`), `admin_panel`, `skins_panel`, `voice_panel`, `world_rules_panel`, `server_settings_pane`, `bug_report_pane`, `config_review_panel`, `net_stats_panel`, `console` | ## `l10n/` — the player's language -`l10n` (the lookups `T`, `Tc`, `Tn`, `Tcn`, `Label`, `Fmt`, and `Init`, which builds the one catalogue), +`l10n` (the lookups `T`, `Tc`, `Tn`, `Label`, `Fmt`, and `Init`, which builds the one catalogue; the markers in `mark`), `po_reader` (a gettext `.po` file read, the only parser of a translator's text), `plural_expr` (a catalogue's plural rule, compiled once), `printf_check` (the conversions a message may carry), `fmt` (the positional -formatter), `catalog` and `locale_choice` (private to the folder), `l10n_selftest`. The packs the mod ships +formatter), `catalog`, `locale_choice` and `quiet` -- these and the three before them private to `src/l10n/` -- +`l10n_selftest`. The packs the mod ships are `src/votv-coop/locale/.po`, embedded by name, beside the template `multivoid.pot` that `.github/ci/l10n_pot.py` extracts and gates (the files that may hold player text are its allowlist); `.github/ci/l10n_vectors.py` writes the selftest's plural vectors. diff --git a/src/votv-coop/include/coop/comms/chat_feed.h b/src/votv-coop/include/coop/comms/chat_feed.h index 6df762831..7fe05d026 100644 --- a/src/votv-coop/include/coop/comms/chat_feed.h +++ b/src/votv-coop/include/coop/comms/chat_feed.h @@ -123,7 +123,6 @@ void PushAction(const std::string& utf8Line, uint8_t nickBegin, uint8_t nickByte // join announces, since a client reports world-ready before its loading screen clears and an // immediate line looks premature. The delay is wall clock: when the line appears, not how // long it lives. Game thread. -void PushDelayed(const std::wstring& line, uint64_t delayMs, Keep keep); // The UTF-8 form of the delayed push; the wide overload forwards here. void PushDelayed(const std::string& utf8Line, uint64_t delayMs, Keep keep); diff --git a/src/votv-coop/include/coop/comms/peer_action_feed.h b/src/votv-coop/include/coop/comms/peer_action_feed.h index ef2fe5169..faba3bc44 100644 --- a/src/votv-coop/include/coop/comms/peer_action_feed.h +++ b/src/votv-coop/include/coop/comms/peer_action_feed.h @@ -59,8 +59,6 @@ void AnnounceDirect(uint8_t slot, Action action, const std::wstring& arg = {}); // AnnounceDirect for an action that takes a count (SoldFor): the plural form follows `n`. void AnnounceDirectCount(uint8_t slot, Action action, long long n); -// The action's English sentence, for a log line beside the feed's. -const char* English(Action action); // The ui.chat.peer_actions toggle: Enabled reads the live value (lazy-loads the row's // resolved value on first call). A change is a config SetValue; the subscriber applies it. diff --git a/src/votv-coop/include/coop/dev/chat_drill.h b/src/votv-coop/include/coop/dev/chat_drill.h index 71523f3b3..cac303c3b 100644 --- a/src/votv-coop/include/coop/dev/chat_drill.h +++ b/src/votv-coop/include/coop/dev/chat_drill.h @@ -8,8 +8,8 @@ // joiner's seed and the lines said while it loaded. i18n: all four, EVERY peer judges the others' // lines, in four scripts, and nicknames; its wait restarts its budget at each new line and its // ABORT names the lines still missing. span: every peer pushes a peer-action line with its nick -// mid-line and one whose nick the 255-byte cut reaches, and judges what the feed hands back. Lines tagged [CHAT-DRILL]: " PASS", " FAIL: -// " (roles, indices, counts; never chat text), " ABORT: " (not a measurement: a +// mid-line and one whose nick the 255-byte cut reaches, and judges what the feed hands back. +// Lines tagged [CHAT-DRILL]: " PASS", " FAIL: " (roles, indices, counts; never chat text), " ABORT: " (not a measurement: a // wait ran out or the case could not be exercised), and the seed arm's rig cue "host HAS c1 4". // Reds: chat_no_retain (history), chat_seed_suppress (seed), chat_drill_bad_nick (i18n, on c1), // chat_span_prefix (span). diff --git a/src/votv-coop/include/coop/text/utf8_codec.h b/src/votv-coop/include/coop/text/utf8_codec.h index 661df1d83..06e31cece 100644 --- a/src/votv-coop/include/coop/text/utf8_codec.h +++ b/src/votv-coop/include/coop/text/utf8_codec.h @@ -38,6 +38,8 @@ bool FromUtf8Strict(const char* p, size_t n, std::wstring* out); // registries). Lossy where the source is not well-formed, which is acceptable // only because the source is ours; never use it on a peer's bytes. std::wstring FromUtf8Lossy(const char* p, size_t n); +// The same for a NUL-terminated string of ours (a looked-up label a native widget takes wide). +std::wstring FromUtf8Lossy(const char* nulTerminated); // Strip C0 control bytes (keeping TAB), DEL, and the three line separators U+0085, U+2028 and // U+2029 (each whole sequence) from a UTF-8 string. A DENYLIST: it removes what is dangerous at diff --git a/src/votv-coop/include/ui/fonts.h b/src/votv-coop/include/ui/fonts.h index cbe8017bd..4fac26582 100644 --- a/src/votv-coop/include/ui/fonts.h +++ b/src/votv-coop/include/ui/fonts.h @@ -59,7 +59,7 @@ ImFont* FontFor(Role r); float PxFor(Role r); const char* FamilyLabel(Family f); // "JetBrains Mono", ... -const char* RoleLabel(Role r); // "Menu / panels", "Chat", "Net stats", "Nameplates" +const char* RoleLabel(Role r); // the role's name in the player's language ("Menu / panels" in English) // The role's family. Render thread (F1 menu). A pane changes it by setting the ui.font. row; // ApplyRowsIfChanged applies it. diff --git a/src/votv-coop/locale/en_XA.po b/src/votv-coop/locale/en_XA.po index 50253b9bb..dd952427f 100644 --- a/src/votv-coop/locale/en_XA.po +++ b/src/votv-coop/locale/en_XA.po @@ -13,8 +13,8 @@ msgid "%.0f cm" msgstr "[[%.0f cm]]" #, c-format -msgid "%.1f / %.1f MB" -msgstr "[[%.1f / %.1f MB]]" +msgid "%1$.1f / %2$.1f MB" +msgstr "[[%1$.1f / %2$.1f MB]]" #, c-format msgid "%1$s (%2$llu KB)" @@ -156,6 +156,10 @@ msgstr "[[%1$s was turned away: official build %2$s [%3$s]]]" msgid "%1$s was turned away: unofficial build %2$s [%3$s]" msgstr "[[%1$s was turned away: unofficial build %2$s [%3$s]]]" +#, c-format +msgid "%1$u / %2$u objects" +msgstr "[[%1$u / %2$u objects]]" + #, c-format msgid "%1$u/%2$u players sleeping" msgstr "[[%1$u/%2$u players sleeping]]" @@ -196,10 +200,6 @@ msgstr[1] "[[%llu servers]]" msgid "%s (without passwords)" msgstr "[[%s (without passwords)]]" -#, c-format -msgid "%u / %u objects" -msgstr "[[%u / %u objects]]" - msgid "(hidden -- friends join by invite/IP)" msgstr "[[(hidden -- friends join by invite/IP)]]" @@ -448,6 +448,9 @@ msgstr "[[Duplicate settings with different values]]" msgid "Each surface picks its own family; applies on the next frame." msgstr "[[Each surface picks its own family; applies on the next frame.]]" +msgid "Enter = send, Esc = close, Up/Down = history" +msgstr "[[Enter = send, Esc = close, Up/Down = history]]" + msgid "Everyone in the registry is online." msgstr "[[Everyone in the registry is online.]]" @@ -513,8 +516,8 @@ msgid "Joining %s" msgstr "[[Joining %s]]" #, c-format -msgid "Keep line %d: %s" -msgstr "[[Keep line %d: %s]]" +msgid "Keep line %1$d: %2$s" +msgstr "[[Keep line %1$d: %2$s]]" msgid "LAN" msgstr "[[LAN]]" @@ -736,8 +739,8 @@ msgid "Players" msgstr "[[Players]]" #, c-format -msgid "Players: %d/%d" -msgstr "[[Players: %d/%d]]" +msgid "Players: %1$d/%2$d" +msgstr "[[Players: %1$d/%2$d]]" msgid "Please describe what happened (at least 20 characters)." msgstr "[[Please describe what happened (at least 20 characters).]]" @@ -1117,8 +1120,8 @@ msgid "Threshold near 0 dB -- the mic will almost never trigger. Try -50 dB." msgstr "[[Threshold near 0 dB -- the mic will almost never trigger. Try -50 dB.]]" #, c-format -msgid "Tidied: %d duplicate line(s) merged, %d setting(s) placed under their sections, %d unknown/invalid line(s) commented out. Anything still listed above needs a choice or can't be fixed from the file." -msgstr "[[Tidied: %d duplicate line(s) merged, %d setting(s) placed under their sections, %d unknown/invalid line(s) commented out. Anything still listed above needs a choice or can't be fixed from the file.]]" +msgid "Tidied: %1$d duplicate line(s) merged, %2$d setting(s) placed under their sections, %3$d unknown/invalid line(s) commented out. Anything still listed above needs a choice or can't be fixed from the file." +msgstr "[[Tidied: %1$d duplicate line(s) merged, %2$d setting(s) placed under their sections, %3$d unknown/invalid line(s) commented out. Anything still listed above needs a choice or can't be fixed from the file.]]" msgid "Tidy up multivoid.ini" msgstr "[[Tidy up multivoid.ini]]" @@ -1378,6 +1381,9 @@ msgstr "[[pkt/s %1$.0f in %2$.0f out]]" msgid "reason (shown in the Banned list)" msgstr "[[reason (shown in the Banned list)]]" +msgid "say:" +msgstr "[[say:]]" + msgid "starting up" msgstr "[[starting up]]" diff --git a/src/votv-coop/locale/multivoid.pot b/src/votv-coop/locale/multivoid.pot index 2e63ae8af..609192059 100644 --- a/src/votv-coop/locale/multivoid.pot +++ b/src/votv-coop/locale/multivoid.pot @@ -14,7 +14,7 @@ msgstr "" #: src/ui/loading_screen.cpp #, c-format -msgid "%.1f / %.1f MB" +msgid "%1$.1f / %2$.1f MB" msgstr "" #: src/ui/bug_report_pane.cpp @@ -191,6 +191,11 @@ msgstr "" msgid "%1$s was turned away: unofficial build %2$s [%3$s]" msgstr "" +#: src/ui/loading_screen.cpp +#, c-format +msgid "%1$u / %2$u objects" +msgstr "" + #: src/coop/player/sleep_sync.cpp #, c-format msgid "%1$u/%2$u players sleeping" @@ -240,11 +245,6 @@ msgstr[1] "" msgid "%s (without passwords)" msgstr "" -#: src/ui/loading_screen.cpp -#, c-format -msgid "%u / %u objects" -msgstr "" - #: src/ui/scoreboard.cpp msgid "(hidden -- friends join by invite/IP)" msgstr "" @@ -556,6 +556,10 @@ msgstr "" msgid "Each surface picks its own family; applies on the next frame." msgstr "" +#: src/ui/chat_input.cpp +msgid "Enter = send, Esc = close, Up/Down = history" +msgstr "" + #: src/ui/admin_panel.cpp msgid "Everyone in the registry is online." msgstr "" @@ -635,7 +639,7 @@ msgstr "" #: src/ui/config_review_panel.cpp #, c-format -msgid "Keep line %d: %s" +msgid "Keep line %1$d: %2$s" msgstr "" #: src/ui/link_format.cpp @@ -921,7 +925,7 @@ msgstr "" #: src/ui/server_browser_panels.cpp src/ui/server_browser_rows.cpp #, c-format -msgid "Players: %d/%d" +msgid "Players: %1$d/%2$d" msgstr "" #: src/coop/bug_report/report_form.cpp @@ -1387,7 +1391,7 @@ msgstr "" #: src/ui/config_review_panel.cpp #, c-format -msgid "Tidied: %d duplicate line(s) merged, %d setting(s) placed under their sections, %d unknown/invalid line(s) commented out. Anything still listed above needs a choice or can't be fixed from the file." +msgid "Tidied: %1$d duplicate line(s) merged, %2$d setting(s) placed under their sections, %3$d unknown/invalid line(s) commented out. Anything still listed above needs a choice or can't be fixed from the file." msgstr "" #: src/ui/config_review_panel.cpp @@ -1719,6 +1723,10 @@ msgstr "" msgid "reason (shown in the Banned list)" msgstr "" +#: src/ui/chat_input.cpp +msgid "say:" +msgstr "" + #: src/ui/dev_menu.cpp msgid "starting up" msgstr "" diff --git a/src/votv-coop/locale/zh_CN.po b/src/votv-coop/locale/zh_CN.po index c04a58bd8..1110917c7 100644 --- a/src/votv-coop/locale/zh_CN.po +++ b/src/votv-coop/locale/zh_CN.po @@ -16,7 +16,7 @@ msgid "%.0f cm" msgstr "" #, c-format -msgid "%.1f / %.1f MB" +msgid "%1$.1f / %2$.1f MB" msgstr "" #, c-format @@ -157,6 +157,10 @@ msgstr "" msgid "%1$s was turned away: unofficial build %2$s [%3$s]" msgstr "" +#, c-format +msgid "%1$u / %2$u objects" +msgstr "" + #, c-format msgid "%1$u/%2$u players sleeping" msgstr "" @@ -195,10 +199,6 @@ msgstr[0] "" msgid "%s (without passwords)" msgstr "" -#, c-format -msgid "%u / %u objects" -msgstr "" - msgid "(hidden -- friends join by invite/IP)" msgstr "" @@ -436,6 +436,9 @@ msgstr "" msgid "Each surface picks its own family; applies on the next frame." msgstr "" +msgid "Enter = send, Esc = close, Up/Down = history" +msgstr "" + msgid "Everyone in the registry is online." msgstr "" @@ -497,7 +500,7 @@ msgid "Joining %s" msgstr "" #, c-format -msgid "Keep line %d: %s" +msgid "Keep line %1$d: %2$s" msgstr "" msgid "LAN" @@ -714,7 +717,7 @@ msgid "Players" msgstr "" #, c-format -msgid "Players: %d/%d" +msgid "Players: %1$d/%2$d" msgstr "" msgid "Please describe what happened (at least 20 characters)." @@ -1072,7 +1075,7 @@ msgid "Threshold near 0 dB -- the mic will almost never trigger. Try -50 dB." msgstr "" #, c-format -msgid "Tidied: %d duplicate line(s) merged, %d setting(s) placed under their sections, %d unknown/invalid line(s) commented out. Anything still listed above needs a choice or can't be fixed from the file." +msgid "Tidied: %1$d duplicate line(s) merged, %2$d setting(s) placed under their sections, %3$d unknown/invalid line(s) commented out. Anything still listed above needs a choice or can't be fixed from the file." msgstr "整理完成:合并 %d 条重复行,%d 个设置归位,%d 条未知或无效行已注释。上面还列着的,需要你选择,或无法从文件里修复。" msgid "Tidy up multivoid.ini" @@ -1327,6 +1330,9 @@ msgstr "" msgid "reason (shown in the Banned list)" msgstr "" +msgid "say:" +msgstr "说:" + msgid "starting up" msgstr "" diff --git a/src/votv-coop/src/coop/comms/chat_feed.cpp b/src/votv-coop/src/coop/comms/chat_feed.cpp index 8a6f765fb..edf3998d3 100644 --- a/src/votv-coop/src/coop/comms/chat_feed.cpp +++ b/src/votv-coop/src/coop/comms/chat_feed.cpp @@ -409,8 +409,14 @@ void Push(const std::wstring& line, Keep keep) { Push(ToUtf8(line), keep); } void Push(const std::string& utf8Line, Keep keep) { const uint64_t now = NowMs(); AdvanceSuspension(now); + // A composed line may hold a translator's text: control bytes are dropped (tab kept), as the wide + // path's conversion drops them, so a line never breaks the row model the wrap code expects. + std::string clean; + clean.reserve(utf8Line.size()); + for (char ch : utf8Line) + if (static_cast(ch) >= 0x20 || ch == '\t') clean.push_back(ch); Entry e; - SetText(e, utf8Line); + SetText(e, clean); e.key = NextKey(); e.bornMs = now; e.bornSuspendedMs = g_suspendedMs; @@ -463,10 +469,6 @@ void PushAction(const std::string& utf8Line, uint8_t nickBegin, uint8_t nickByte Republish(now); } -void PushDelayed(const std::wstring& line, uint64_t delayMs, Keep keep) { - PushDelayed(ToUtf8(line), delayMs, keep); -} - void PushDelayed(const std::string& utf8Line, uint64_t delayMs, Keep keep) { Pending p; p.text = utf8Line; diff --git a/src/votv-coop/src/coop/comms/peer_action_feed.cpp b/src/votv-coop/src/coop/comms/peer_action_feed.cpp index 27dcd4e95..45c3ee886 100644 --- a/src/votv-coop/src/coop/comms/peer_action_feed.cpp +++ b/src/votv-coop/src/coop/comms/peer_action_feed.cpp @@ -176,9 +176,5 @@ void AnnounceDirectCount(uint8_t slot, Action action, long long n) { Say(slot, *row, std::wstring(), n); } -const char* English(Action action) { - const Row* row = RowOf(action); - return row ? row->msgid : "?"; -} } // namespace coop::peer_action_feed diff --git a/src/votv-coop/src/coop/dev/chat_drill.cpp b/src/votv-coop/src/coop/dev/chat_drill.cpp index 76dec4600..d418729a0 100644 --- a/src/votv-coop/src/coop/dev/chat_drill.cpp +++ b/src/votv-coop/src/coop/dev/chat_drill.cpp @@ -317,7 +317,6 @@ void JudgeSeed() { Pass(); } -// The start of every arm, once this peer is ready. False while it is not. // The span arm, on every peer and in memory: a peer-action line whose nick a translated sentence put // mid-line keeps that span through the feed and reads back as it was pushed; a span the 255-byte cut // reaches is dropped, never kept as a coloured tail; a chat row's prefix reads as before. It tests the @@ -343,6 +342,7 @@ void JudgeSpan() { else Pass(); } +// The start of every arm, once this peer is ready. False while it is not. bool Begin(coop::net::Session* session) { g_isHost = session->role() == coop::net::Role::Host; if (!g_isHost && !ClientReady(session)) return false; diff --git a/src/votv-coop/src/coop/items/coingun_sync.cpp b/src/votv-coop/src/coop/items/coingun_sync.cpp index bbf723c9a..d0f900508 100644 --- a/src/votv-coop/src/coop/items/coingun_sync.cpp +++ b/src/votv-coop/src/coop/items/coingun_sync.cpp @@ -488,8 +488,8 @@ void OnReliableResult(const uint8_t* payload, int len) { } else { const auto action = RefusalAction(code); coop::peer_action_feed::AnnounceDirect(local, action); - UE_LOGI("coingun[client]: CoinGunResult code=%u points=%d -- '%s'", static_cast(r.code), - r.points, coop::peer_action_feed::English(action)); + UE_LOGI("coingun[client]: CoinGunResult code=%u points=%d -- refused", static_cast(r.code), + r.points); } } diff --git a/src/votv-coop/src/coop/items/order_sync.cpp b/src/votv-coop/src/coop/items/order_sync.cpp index a079c9867..48a09b6d1 100644 --- a/src/votv-coop/src/coop/items/order_sync.cpp +++ b/src/votv-coop/src/coop/items/order_sync.cpp @@ -394,8 +394,8 @@ void OnRefused(const void* payload, int len) { coop::peer_action_feed::AnnounceDirect( static_cast(coop::players::Registry::Get().LocalPeerId()), action); const int32_t restored = OE::RestoreCartItems(rows); - UE_LOGW("order_sync: order id=%u REFUSED by the host ('%s'); %d of %zu item(s) restored to the " - "cart", p.orderId, coop::peer_action_feed::English(action), restored, rows.size()); + UE_LOGW("order_sync: order id=%u REFUSED by the host (reason %u); %d of %zu item(s) restored to the " + "cart", p.orderId, static_cast(p.reason), restored, rows.size()); } } // namespace diff --git a/src/votv-coop/src/coop/player/sleep_sync.cpp b/src/votv-coop/src/coop/player/sleep_sync.cpp index b10d3ccf8..1ac888b0d 100644 --- a/src/votv-coop/src/coop/player/sleep_sync.cpp +++ b/src/votv-coop/src/coop/player/sleep_sync.cpp @@ -35,7 +35,7 @@ bool IsHost(coop::net::Session* s) { return s->role() == coop::net::Role::Host; void PushCounterLine(uint8_t count, uint8_t total) { if (count == 0) return; - char buf[128]; + char buf[256]; if (l10n::Fmt(buf, sizeof(buf), l10n::T("%1$u/%2$u players sleeping"), static_cast(count), static_cast(total)) < 0) return; diff --git a/src/votv-coop/src/coop/text/utf8_codec.cpp b/src/votv-coop/src/coop/text/utf8_codec.cpp index a8feb9fb5..26a81e81e 100644 --- a/src/votv-coop/src/coop/text/utf8_codec.cpp +++ b/src/votv-coop/src/coop/text/utf8_codec.cpp @@ -78,6 +78,10 @@ std::wstring FromUtf8Lossy(const char* p, size_t n) { return w; } +std::wstring FromUtf8Lossy(const char* nulTerminated) { + return nulTerminated ? FromUtf8Lossy(nulTerminated, std::strlen(nulTerminated)) : std::wstring(); +} + std::string SanitizeUtf8(const char* p, size_t n) { std::string out; out.reserve(n); diff --git a/src/votv-coop/src/l10n/l10n_selftest.cpp b/src/votv-coop/src/l10n/l10n_selftest.cpp index fbccf53e1..ddb85e523 100644 --- a/src/votv-coop/src/l10n/l10n_selftest.cpp +++ b/src/votv-coop/src/l10n/l10n_selftest.cpp @@ -97,10 +97,12 @@ void Conversions(Checks& c) { c.Ok(!Accepts("%.3s") && !Accepts("%05s") && !Accepts("%+u") && !Accepts("%#d"), "flags C leaves undefined are refused"); c.Ok(!Accepts("%123d") && !Accepts("100% sure"), "a three-digit width and a bare '%' are refused"); c.Ok(!Accepts("%--5d") && !Accepts("%00x") && Accepts("%-08.2f"), "a flag repeated is refused, distinct flags are not"); - c.Ok(Match("%s took %d", "%2$d by %1$s"), "a reordered translation matches"); + c.Ok(Match("%1$s took %2$d", "%2$d by %1$s"), "a reordered translation matches"); c.Ok(Match("%d items", "%i items") && Match("%5.2f", "%.1f"), "d and i are one type; width and precision may differ"); c.Ok(!Match("%s took %d", "%s took %s") && !Match("%d", "%ld"), "a type or length change is refused"); c.Ok(!Match("%s and %s", "%s"), "a missing conversion is refused"); + c.Ok(!Match("volume %.2fx", "volume %1$.2fx") && Match("%1$s took %2$d", "%s took %d"), + "a numbered translation of an unnumbered msgid is refused; the reverse is accepted"); } void Formatter(Checks& c) { @@ -163,7 +165,7 @@ void Reader(Checks& c) { "msgid \"%llu peer\"\nmsgid_plural \"%llu peers\"\nmsgstr[0] \"%llu a\"\nmsgstr[1] \"%llu b\"\n" "msgstr[2] \"%llu c\"\n\n" "msgid \"%llu x\"\nmsgid_plural \"%llu xs\"\nmsgstr[0] \"%llu\"\nmsgstr[1] \"\"\nmsgstr[2] \"%llu\"\n\n" // half - "msgid \"%s took %d\"\nmsgstr \"%2$d by %1$s\"\n\n" + "msgid \"%1$s took %2$d\"\nmsgstr \"%2$d by %1$s\"\n\n" "msgid \"%s took\"\nmsgstr \"%d\"\n\n" // a type change: refused "msgid \"Plain\"\nmsgstr \"100%\"\n\n" // a '%' in a plain line: refused "msgid \"Nul\"\nmsgstr \"a\\0b\"\n\n" // a NUL escape: refused @@ -184,7 +186,7 @@ void Reader(Checks& c) { const po::Entry* pl = find("%llu peer", false); c.Ok(pl && pl->plural && pl->str.size() == 3, "a plural entry keeps its forms"); c.Ok(!find("%llu x", false), "a half-filled plural is refused"); - c.Ok(find("%s took %d", false) && !find("%s took", false), "conversions are checked against the msgid"); + c.Ok(find("%1$s took %2$d", false) && !find("%s took", false), "conversions are checked against the msgid"); c.Ok(!find("Plain", false) && !find("Nul", false), "a '%' in a plain line and a NUL escape are refused"); c.Ok(f.refused.size() == 5, "each refused entry is reported once"); @@ -291,6 +293,13 @@ void Embedded(Checks& c) { return TRUE; }, reinterpret_cast(&names)); + // The packs this build must carry: a resource step that dropped one would otherwise pass in silence. + auto has = [&](const wchar_t* n) { + for (const std::wstring& x : names) + if (_wcsicmp(x.c_str(), n) == 0) return true; + return false; + }; + c.Ok(has(L"L10N_ZH_CN") && has(L"L10N_EN_XA"), "the build embeds the zh_CN pack and the en_XA pseudo-locale"); for (const std::wstring& name : names) { HRSRC res = ::FindResourceW(self, name.c_str(), reinterpret_cast(RT_RCDATA)); HGLOBAL glob = res ? ::LoadResource(self, res) : nullptr; diff --git a/src/votv-coop/src/l10n/printf_check.cpp b/src/votv-coop/src/l10n/printf_check.cpp index 32788bca1..0a0eff208 100644 --- a/src/votv-coop/src/l10n/printf_check.cpp +++ b/src/votv-coop/src/l10n/printf_check.cpp @@ -105,6 +105,7 @@ bool Parse(std::string_view s, Parsed* out, const char** why) { } if (top != out->count) return fail("a gap in the argument numbers"); out->args = out->count; + out->numbered = numbered == 1; return true; } @@ -115,6 +116,10 @@ const Conv* ForArg(const Parsed& p, uint8_t arg) { } bool Matches(const Parsed& msgid, const Parsed& tr, const char** why) { + if (tr.numbered && !msgid.numbered) { + if (why) *why = "argument numbers in the translation of a msgid that has none"; + return false; + } if (msgid.args != tr.args) { if (why) *why = "a different number of conversions than the msgid"; return false; diff --git a/src/votv-coop/src/l10n/printf_check.h b/src/votv-coop/src/l10n/printf_check.h index f3dd62581..f5f590191 100644 --- a/src/votv-coop/src/l10n/printf_check.h +++ b/src/votv-coop/src/l10n/printf_check.h @@ -34,6 +34,7 @@ struct Parsed { std::array convs{}; // in format order; `%%` is not one uint8_t count = 0; // conversions in the format uint8_t args = 0; // arguments they read (== count: each exactly once) + bool numbered = false; // the conversions carry argument numbers ("%1$s") }; // Reads `fmt` with the grammar above. False with the reason in `why`; nothing allocates. @@ -41,7 +42,9 @@ bool Parse(std::string_view fmt, Parsed* out, const char** why); // A translation's conversions against its msgid's: the same argument count, and for each // argument number the same type and length (`d` and `i` are one type); flags, width and precision -// may differ. False with the reason. +// may differ. A translation numbers its conversions only when the msgid does: an unnumbered msgid may +// be handed to the CRT's own printf (an ImGui widget's value format), which has no argument numbers. +// False with the reason. bool Matches(const Parsed& msgid, const Parsed& translation, const char** why); // The conversion that reads argument `arg`, or null. diff --git a/src/votv-coop/src/ui/browser_input_screens.cpp b/src/votv-coop/src/ui/browser_input_screens.cpp index 455e7745a..b9ebfc437 100644 --- a/src/votv-coop/src/ui/browser_input_screens.cpp +++ b/src/votv-coop/src/ui/browser_input_screens.cpp @@ -30,11 +30,6 @@ namespace ui::browser_input_screens { namespace { -// A looked-up label is UTF-8; the native widgets take wide text. -std::wstring Wide(const char* utf8) { - return coop::text::FromUtf8Lossy(utf8, std::strlen(utf8)); -} - namespace E = ue_wrap::engine; namespace U = ue_wrap::umg; namespace P = ue_wrap::profile; @@ -166,17 +161,17 @@ bool BuildOne(void* switcher, Kind kind, void* backDonor) { NS::WindowShell shell; const float windowH = kWindowH + (spec.label2 ? kSecondFieldH : 0.f); - if (!NS::BuildWindowShell(switcher, kWindowW, windowH, Wide(l10n::T(spec.title)).c_str(), + if (!NS::BuildWindowShell(switcher, kWindowW, windowH, coop::text::FromUtf8Lossy(l10n::T(spec.title)).c_str(), shell)) return false; void* col = shell.column; - NS::AddText(col, Wide(l10n::T(spec.label)).c_str(), 16, kAccent, NS::kJustLeft, 0.f); - s.field = TF::Create(col, Wide(l10n::T(spec.hint)).c_str(), spec.maxLen, kFieldW); + NS::AddText(col, coop::text::FromUtf8Lossy(l10n::T(spec.label)).c_str(), 16, kAccent, NS::kJustLeft, 0.f); + s.field = TF::Create(col, coop::text::FromUtf8Lossy(l10n::T(spec.hint)).c_str(), spec.maxLen, kFieldW); if (!s.field) return false; if (spec.label2) { - NS::AddText(col, Wide(l10n::T(spec.label2)).c_str(), 16, kAccent, NS::kJustLeft, 0.f); - s.field2 = TF::Create(col, Wide(l10n::T(spec.hint2)).c_str(), spec.maxLen2, kFieldW); + NS::AddText(col, coop::text::FromUtf8Lossy(l10n::T(spec.label2)).c_str(), 16, kAccent, NS::kJustLeft, 0.f); + s.field2 = TF::Create(col, coop::text::FromUtf8Lossy(l10n::T(spec.hint2)).c_str(), spec.maxLen2, kFieldW); // The first field is released on the way out: this builder is retried from the menu tick, // and a bare return past a successful Create would leak a Field per retry. if (!s.field2) { ReleaseFields(s); return false; } @@ -194,12 +189,12 @@ bool BuildOne(void* switcher, Kind kind, void* backDonor) { // confirm. if (void* footRow = NS::Spawn(L"HorizontalBox", col)) { s.backBtn = NS::BuildButton(footRow, backDonor, - Wide(l10n::Tc("server_browser", "Back")).c_str(), + coop::text::FromUtf8Lossy(l10n::Tc("server_browser", "Back")).c_str(), NS::kBtnFontPx); void* gap = NS::Spawn(L"Spacer", footRow); if (gap) NS::AddHFill(footRow, gap, 1.f, NS::kFill, NS::kFill); s.okBtn = NS::BuildButton(footRow, backDonor, - Wide(l10n::Tc("server_browser", spec.confirm)).c_str(), + coop::text::FromUtf8Lossy(l10n::Tc("server_browser", spec.confirm)).c_str(), NS::kBtnFontPx); // Released here too: the next tick rebuilds and mints a second Field, stranding this one in // the module's live list. diff --git a/src/votv-coop/src/ui/chat_input.cpp b/src/votv-coop/src/ui/chat_input.cpp index 9e420acbb..923240541 100644 --- a/src/votv-coop/src/ui/chat_input.cpp +++ b/src/votv-coop/src/ui/chat_input.cpp @@ -2,6 +2,8 @@ #include "ui/chat_input.h" +#include "l10n/l10n.h" + #include "coop/comms/chat_feed.h" #include "coop/comms/chat_sync.h" #include "coop/commands/command_sync.h" @@ -102,7 +104,7 @@ void Render() { ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoNav; if (ImGui::Begin("##coop_chat_input", nullptr, flags)) { - ImGui::TextDisabled("say:"); + ImGui::TextDisabled("%s", l10n::T("say:")); ImGui::SameLine(); // The chat COLUMN owns one width: the input bar matches the feed's word-wrap width (ui/hud.cpp // DrawChat wrapW) so the bar sits exactly under the messages instead of striping most of the @@ -114,7 +116,7 @@ void Render() { // The key hints live in the field's PLACEHOLDER (visible while empty) // instead of a trailing label -- zero width cost, same discoverability. const bool submitted = ImGui::InputTextWithHint( - "##chatline", "Enter = send, Esc = close, Up/Down = history", + "##chatline", l10n::T("Enter = send, Esc = close, Up/Down = history"), g_buf, sizeof(g_buf), ImGuiInputTextFlags_EnterReturnsTrue | ImGuiInputTextFlags_CallbackHistory, &HistoryCallback); diff --git a/src/votv-coop/src/ui/config_review_panel.cpp b/src/votv-coop/src/ui/config_review_panel.cpp index 30f42ec43..b94f002a6 100644 --- a/src/votv-coop/src/ui/config_review_panel.cpp +++ b/src/votv-coop/src/ui/config_review_panel.cpp @@ -122,7 +122,7 @@ void RenderRowsOfType(const std::vector& rows, CR::Row::Type type) { // The words are looked up; the id after "###" stays the runtime pair that names // this key's line, so a translation never moves it. char keep[256]; - l10n::Fmt(keep, sizeof(keep), l10n::T("Keep line %d: %s"), dl.lineNo, dl.value.c_str()); + l10n::Fmt(keep, sizeof(keep), l10n::T("Keep line %1$d: %2$s"), dl.lineNo, dl.value.c_str()); char btn[320]; std::snprintf(btn, sizeof(btn), "%s###cfgrev_%s_%d", keep, r.key.c_str(), dl.lineNo); @@ -279,8 +279,8 @@ void Render() { // Three counts in one sentence: Tn takes one, so the "(s)" forms stay. char tidied[512]; l10n::Fmt(tidied, sizeof(tidied), - l10n::T("Tidied: %d duplicate line(s) merged, %d setting(s) placed " - "under their sections, %d unknown/invalid line(s) commented " + l10n::T("Tidied: %1$d duplicate line(s) merged, %2$d setting(s) placed " + "under their sections, %3$d unknown/invalid line(s) commented " "out. Anything still listed above needs a choice or can't " "be fixed from the file."), g_tidyOutcome.collapsed, g_tidyOutcome.placed, g_tidyOutcome.retired); diff --git a/src/votv-coop/src/ui/dev_menu.cpp b/src/votv-coop/src/ui/dev_menu.cpp index 1e9cdf36d..e65349ecb 100644 --- a/src/votv-coop/src/ui/dev_menu.cpp +++ b/src/votv-coop/src/ui/dev_menu.cpp @@ -307,6 +307,7 @@ void RequestSelect(const char* category, const char* sub) { if (!category || !sub || g_wantSelect.load(std::memory_order_acquire)) return; g_wantCat = category; g_wantSub = sub; + g_drawn.store(0, std::memory_order_release); // DrawnSelection waits for a draw after this g_wantSelect.store(true, std::memory_order_release); } @@ -401,7 +402,7 @@ void Render() { // different for me" without asking the user to dig through logs. { const char* rhi = ui::overlay_backend::Kind(); - char line[128]; + char line[256]; l10n::Fmt(line, sizeof(line), l10n::T("Graphics API: %s"), rhi ? rhi : l10n::T("starting up")); ImGui::Spacing(); ImGui::TextDisabled("%s", line); diff --git a/src/votv-coop/src/ui/loading_screen.cpp b/src/votv-coop/src/ui/loading_screen.cpp index 67750cb20..585ed3b0f 100644 --- a/src/votv-coop/src/ui/loading_screen.cpp +++ b/src/votv-coop/src/ui/loading_screen.cpp @@ -165,7 +165,7 @@ void Render() { char overlay[256]; // MB, one decimal -- bytes are unreadable at this size and a percentage // alone does not tell the player the transfer is large by nature. - l10n::Fmt(overlay, sizeof(overlay), l10n::T("%.1f / %.1f MB"), + l10n::Fmt(overlay, sizeof(overlay), l10n::T("%1$.1f / %2$.1f MB"), static_cast(v.doneBytes) / (1024.0 * 1024.0), static_cast(v.totalBytes) / (1024.0 * 1024.0)); ImGui::PushStyleColor(ImGuiCol_PlotHistogram, ImVec4(0.36f, 0.59f, 0.82f, 1.0f)); @@ -175,7 +175,7 @@ void Render() { } else if (v.phase == jp::Phase::Receiving && v.total > 0) { const float frac = static_cast(v.applied) / static_cast(v.total); char overlay[256]; - l10n::Fmt(overlay, sizeof(overlay), l10n::T("%u / %u objects"), v.applied, v.total); + l10n::Fmt(overlay, sizeof(overlay), l10n::T("%1$u / %2$u objects"), v.applied, v.total); ImGui::PushStyleColor(ImGuiCol_PlotHistogram, ImVec4(0.36f, 0.59f, 0.82f, 1.0f)); ImGui::PushStyleColor(ImGuiCol_FrameBg, ImVec4(0.10f, 0.12f, 0.16f, 1.0f)); ImGui::ProgressBar(frac > 1.0f ? 1.0f : frac, ImVec2(barW, 0.0f), overlay); diff --git a/src/votv-coop/src/ui/server_browser_actions.cpp b/src/votv-coop/src/ui/server_browser_actions.cpp index 3f5172d26..b9869edd9 100644 --- a/src/votv-coop/src/ui/server_browser_actions.cpp +++ b/src/votv-coop/src/ui/server_browser_actions.cpp @@ -34,11 +34,6 @@ namespace NS = ui::native_screen; namespace sm = coop::session_manager; namespace SB = ui::server_browser_native; -// A looked-up label is UTF-8; the native widgets take wide text. -std::wstring Wide(const char* utf8) { - return coop::text::FromUtf8Lossy(utf8, std::strlen(utf8)); -} - void* g_connect = nullptr; void* g_host = nullptr; void* g_refresh = nullptr; @@ -143,7 +138,7 @@ bool BuildConnect(void* parent, void* donorBtn) { U::SetContent(rowBox, row); if (void* s = NS::AddVFill(parent, rowBox, 0.f, NS::kFill, NS::kTop)) NS::SetSlotPadding(s, P::off::UVerticalBoxSlot_Padding, 0.f, 0.f, 0.f, 6.f); - g_connect = NS::BuildButton(row, donorBtn, Wide(l10n::Tc("server_browser", "Connect")).c_str(), + g_connect = NS::BuildButton(row, donorBtn, coop::text::FromUtf8Lossy(l10n::Tc("server_browser", "Connect")).c_str(), NS::kBtnFontPx); if (!g_connect) { UE_LOGE("server_browser_actions: could not build CONNECT -- the screen would have " @@ -193,7 +188,7 @@ bool Build(void* parent, void* donorBtn) { if (void* s = NS::AddVFill(parent, rowBox, 0.f, NS::kFill, NS::kTop)) NS::SetSlotPadding(s, P::off::UVerticalBoxSlot_Padding, 0.f, 0.f, 0.f, 4.f); } - void* btn = NS::BuildButton(row, donorBtn, Wide(l10n::T(cells[i].label)).c_str(), + void* btn = NS::BuildButton(row, donorBtn, coop::text::FromUtf8Lossy(l10n::T(cells[i].label)).c_str(), NS::kBtnFontPx); if (!btn) { UE_LOGE("server_browser_actions: could not build the '%s' action -- the grid " diff --git a/src/votv-coop/src/ui/server_browser_native.cpp b/src/votv-coop/src/ui/server_browser_native.cpp index e1b1f4fd8..43e9979a9 100644 --- a/src/votv-coop/src/ui/server_browser_native.cpp +++ b/src/votv-coop/src/ui/server_browser_native.cpp @@ -79,11 +79,6 @@ using NS::AddText; using NS::AddFramedBox; using NS::BuildButton; -// A looked-up label is UTF-8; the native widgets take wide text. -std::wstring Wide(const char* utf8) { - return coop::text::FromUtf8Lossy(utf8, std::strlen(utf8)); -} - // Only the window's colours; the row palette lives with the rows. const FLinearColor kPanel = NS::Panel(); // window fill const FLinearColor kText = NS::Text(); // the default: most text is white @@ -205,7 +200,7 @@ bool BuildScreen(void* switcher) { // authors its own frame: it is the canary for a moved menu layout. NS::WindowShell shell; if (!NS::BuildWindowShell(switcher, kWindowW, kWindowH, - Wide(l10n::T("Multivoid - Server Browser")).c_str(), shell)) + coop::text::FromUtf8Lossy(l10n::T("Multivoid - Server Browser")).c_str(), shell)) return false; void* root = shell.root; void* col = shell.column; @@ -266,7 +261,7 @@ bool BuildScreen(void* switcher) { // runs all the way down with no dead band beside Back. if (void* footRow = Spawn(L"HorizontalBox", leftCol)) { // Sentence case: VOTV uppercases no button label anywhere. - g_backBtn = BuildButton(footRow, backDonor, Wide(l10n::Tc("server_browser", "Back")).c_str(), + g_backBtn = BuildButton(footRow, backDonor, coop::text::FromUtf8Lossy(l10n::Tc("server_browser", "Back")).c_str(), ui::native_screen::kBtnFontPx); if (!g_backBtn) return false; NS::SetHSlot(NS::SlotOf(g_backBtn), 0.f, NS::kLeft, kCenter); diff --git a/src/votv-coop/src/ui/server_browser_panels.cpp b/src/votv-coop/src/ui/server_browser_panels.cpp index 418bc72f0..f27cabfa1 100644 --- a/src/votv-coop/src/ui/server_browser_panels.cpp +++ b/src/votv-coop/src/ui/server_browser_panels.cpp @@ -25,11 +25,6 @@ namespace ui::server_browser_panels { namespace { -// A looked-up label is UTF-8; the native widgets take wide text. -std::wstring Wide(const char* utf8) { - return coop::text::FromUtf8Lossy(utf8, std::strlen(utf8)); -} - namespace E = ue_wrap::engine; namespace U = ue_wrap::umg; namespace P = ue_wrap::profile; @@ -185,7 +180,7 @@ bool BuildDetails(void* parent) { // count changes with the selection and an auto-sized box would have slid the pane below it // on every click; a fill slot cannot do that, since its height comes from the column, not // its content. - void* col = SectionBody(parent, kPanel, Wide(l10n::T("Server info:")).c_str(), 1.f, 0.f); + void* col = SectionBody(parent, kPanel, coop::text::FromUtf8Lossy(l10n::T("Server info:")).c_str(), 1.f, 0.f); if (!col) return false; // The name is the panel's own subject and gets the emphasis the row gives it. g_dName = Line{DetailLine(col, 20, kText), {}}; @@ -300,7 +295,7 @@ void Sync(bool force) { g_dVersion.Set(ver); char players[256]; - l10n::Fmt(players, sizeof(players), l10n::T("Players: %d/%d"), r.playersCur, + l10n::Fmt(players, sizeof(players), l10n::T("Players: %1$d/%2$d"), r.playersCur, r.playersMax); g_dPlayers.Set(players); // The direct flag is parsed off the wire and had no reader on this screen: a direct host is diff --git a/src/votv-coop/src/ui/server_browser_rows.cpp b/src/votv-coop/src/ui/server_browser_rows.cpp index c968d6109..f8672700f 100644 --- a/src/votv-coop/src/ui/server_browser_rows.cpp +++ b/src/votv-coop/src/ui/server_browser_rows.cpp @@ -513,7 +513,7 @@ void Sync() { SetRowText(rp.text[kCellFlag], MismatchMark(r)); SetRowText(rp.text[kCellLink], ui::link_format::LobbyLinkLabel(r.link)); char players[256]; - l10n::Fmt(players, sizeof(players), l10n::T("Players: %d/%d"), r.playersCur, r.playersMax); + l10n::Fmt(players, sizeof(players), l10n::T("Players: %1$d/%2$d"), r.playersCur, r.playersMax); SetRowText(rp.text[kCellPlayers], players); // Selection is keyed on the lobby, not the row index: the set churns and one host leaving // shifts every row after it, so an index-keyed selection would move to whatever landed From 51c598b7c2d37c01bb248142c1ac5ca7d8915ea9 Mon Sep 17 00:00:00 2001 From: pelmentor Date: Sat, 10 Oct 2026 07:50:38 +0600 Subject: [PATCH 097/306] [ui] the Chinese script through a Windows face, held to its script Under a Chinese pack the overlay merges a Windows face -- msyh.ttc first, the candidates of #41's merge -- read once and held, merged last into every role with its own exclude list: the generated table united with everything outside the script, so it draws only the ideographs and the punctuation a Chinese line uses, never Latin, never U+3000, U+3007 or the fullwidth letters. The atlas watcher is told: its superset check allows that script, its pack-failure check asks each source whether it has a glyph and takes it, its exclude check holds the named face to its own list and every other to the table, and its selftest gains a CJK arm. Co-Authored-By: Slintchen <80808676+Slintchen@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 --- src/votv-coop/include/ui/atlas_watch.h | 30 ++++-- src/votv-coop/src/ui/atlas_watch.cpp | 107 +++++++++++++++++++-- src/votv-coop/src/ui/fonts.cpp | 126 ++++++++++++++++++++++++- 3 files changed, 246 insertions(+), 17 deletions(-) diff --git a/src/votv-coop/include/ui/atlas_watch.h b/src/votv-coop/include/ui/atlas_watch.h index 34ec5f37f..c199170c8 100644 --- a/src/votv-coop/include/ui/atlas_watch.h +++ b/src/votv-coop/include/ui/atlas_watch.h @@ -1,22 +1,36 @@ // ui/atlas_watch.h -- what the font atlas is doing, asserted every frame. // // ImGui 1.92's atlas is LAZY: a codepoint is rasterised the first time something draws it, the -// texture grows and repacks under pressure, and bakes are discarded when the packer needs room. -// Nothing ever finishes it, so no single boot-time check can speak for it. Three things are watched -// continuously, each with its OWN trigger, because they fail independently. -// 1. THE SUPERSET INVARIANT -- every baked glyph must be in the repertoire. The mechanism is -// subtractive (GlyphExcludeRanges), so a config missing the field, or a Windows fallback face -// carrying scripts our embedded faces do not, bakes a SUPERSET; the fold table treats those -// codepoints as the sentinel, and two legible names collapse to one key. Triggered by a change -// in a baked's Glyphs.Size. +// texture grows and repacks under pressure, and bakes are discarded when the packer needs room, so +// no single boot-time check can speak for it. Three watches, each with its OWN trigger: +// 1. THE SUPERSET INVARIANT -- every baked glyph must be in the repertoire, or in the script +// AllowScript names: the system face merged for the active language (ui/fonts.cpp) bakes that +// script on purpose, and nothing else. The mechanism is subtractive (GlyphExcludeRanges), so a +// config missing the field, or a Windows fallback face carrying scripts our embedded faces do +// not, bakes a SUPERSET; the fold table treats those codepoints as the sentinel, and two +// legible names collapse to one key. Triggered by a change in a baked's Glyphs.Size. // 2. PACK FAILURE -- a glyph that could not fit gets IndexLookup[cp] = NOT_FOUND and draws the // fallback box for the LIFE of that baked, which never heals, because a baked drawn every // frame is never discarded. Glyphs.Size does not move, so the trigger is an edge on the // packer's discarded surface or on the texture's UniqueID. #pragma once +#include "coop/text/repertoire.h" // CodepointRange + +#include + namespace ui::atlas_watch { +// The ImFontConfig::Name of the system face ui/fonts.cpp merges for the active language's script, so +// the exclude check holds that source to its own list and every other to the generated table. +inline constexpr const char kSystemSourceName[] = "multivoid:system"; + +// The script ranges that system face may bake: a baked glyph inside them is expected, not a superset +// fault, and the exclude check holds the named source to the generated table united with their +// complement. Called by ui/fonts.cpp each time it merges the face, before the next OnFrame; the +// table is static and is kept across a context's destruction. Render thread. +void AllowScript(const coop::text::CodepointRange* ranges, size_t count); + // Call once per frame, INSIDE the frame (after ImGui::NewFrame). In-frame is required, not // incidental: the deliberate emoji bake the colour check performs must happen where baking is // legal, and running it out of frame poisons ImFontAtlas::TexIsBuilt. diff --git a/src/votv-coop/src/ui/atlas_watch.cpp b/src/votv-coop/src/ui/atlas_watch.cpp index 10f183cc7..a9ca92d7b 100644 --- a/src/votv-coop/src/ui/atlas_watch.cpp +++ b/src/votv-coop/src/ui/atlas_watch.cpp @@ -8,6 +8,9 @@ #include "imgui.h" #include "imgui_internal.h" // ImFontAtlasBuilder: the packer counters + BakedPool +#include +#include +#include namespace ui::atlas_watch { namespace { @@ -32,6 +35,60 @@ int g_lastDiscardedSurface = -1; // The per-baked glyph count, keyed by baked id; ImGui storage because the pool is unbounded // and entries are reused for other font and size pairs. ImGuiStorage g_glyphCount; + +// The script the active language's system face may bake (AllowScript), or none. +const coop::text::CodepointRange* g_allowed = nullptr; +size_t g_allowedCount = 0; + +bool Allowed(uint32_t cp) { + for (size_t i = 0; i < g_allowedCount; ++i) + if (cp >= g_allowed[i].begin && cp <= g_allowed[i].end) return true; + return false; +} + +// Whether one source refuses a codepoint by its own exclude list (ImGui's accept test is static in +// imgui_draw.cpp, so the walk is repeated here), and whether its face carries it at all. +bool SourceExcludes(const ImFontConfig* src, uint32_t cp) { + for (const ImWchar* p = src->GlyphExcludeRanges; p && p[0]; p += 2) + if (cp >= p[0] && cp <= p[1]) return true; + return false; +} +bool SourceCarries(ImFontAtlas* atlas, ImFontConfig* src, uint32_t cp) { + const ImFontLoader* loader = src->FontLoader ? src->FontLoader : atlas->FontLoader; + return loader && loader->FontSrcContainsGlyph && + loader->FontSrcContainsGlyph(atlas, src, static_cast(cp)); +} +// A source that has the codepoint and does not forbid it -- what a bake of it can come from. +bool SomeSourceTakes(ImFont* font, uint32_t cp) { + for (ImFontConfig* src : font->Sources) + if (SourceCarries(font->OwnerAtlas, src, cp) && !SourceExcludes(src, cp)) return true; + return false; +} +bool SourceNamed(ImFont* font, unsigned idx, const char* name) { + return idx < static_cast(font->Sources.Size) && std::strcmp(font->Sources[idx]->Name, name) == 0; +} + +// The list the system source must carry: the generated table united with the complement of the +// allowed script over U+0001..U+10FFFF, merged. Derived here from the same two inputs fonts.cpp +// derives it from, so the check is of what fonts.cpp built, not a copy of it. +std::vector SystemExpected() { + size_t n = 0; + const coop::text::CodepointRange* gen = coop::text::ExcludeRanges(&n); + std::vector all(gen, gen + n); + uint32_t next = 1; + for (size_t i = 0; i < g_allowedCount; ++i) { + if (g_allowed[i].begin > next) all.push_back({next, g_allowed[i].begin - 1}); + next = g_allowed[i].end + 1; + } + if (next <= 0x10FFFF) all.push_back({next, 0x10FFFF}); + std::sort(all.begin(), all.end(), [](const auto& x, const auto& y) { return x.begin < y.begin; }); + std::vector merged; + for (const auto& r : all) { + if (!merged.empty() && r.begin <= merged.back().end + 1) merged.back().end = (std::max)(merged.back().end, r.end); + else merged.push_back(r); + } + return merged; +} // The regime complaint, once per process: with the capability flag off, every check in this // file is green by construction and would read as evidence. bool g_warnedRegime = false; @@ -63,7 +120,7 @@ int ScanNewGlyphs(ImFontBaked& baked, int& outOffenders, uint32_t& outFirst) { for (int i = from; i < now; ++i) { const ImFontGlyph& g = baked.Glyphs[i]; if (g.PackId == ImFontAtlasRectId_Invalid) continue; // synthesised; no pixels - if (coop::text::InRepertoire(g.Codepoint)) continue; + if (coop::text::InRepertoire(g.Codepoint) || Allowed(g.Codepoint)) continue; if (++outOffenders == 1) outFirst = g.Codepoint; } return now - from; @@ -82,8 +139,9 @@ int ScanPackFailures(ImFontBaked& baked, uint32_t& outFirst) { const int n = baked.IndexLookup.Size; for (int cp = 0; cp < n; ++cp) { if (baked.IndexLookup[cp] != kIndexNotFound) continue; - if (coop::text::InExcludeSet(static_cast(cp))) continue; // we forbade it - if (!font->IsGlyphInFont(static_cast(cp))) continue; // nothing has it + // A source that has it and does not forbid it: per source, since the system face carries far + // more than its script and forbids the rest, which a font-wide test would call a failure. + if (!SomeSourceTakes(font, static_cast(cp))) continue; if (++found == 1) outFirst = static_cast(cp); } return found; @@ -124,7 +182,11 @@ void RunSelftest(ImFontAtlas* atlas, ImTextureData* tex) { // The red case. Without one, an always-true instrument is indistinguishable from a working // one. U+4E00 is the first CJK ideograph and no embedded face or donor carries it; if this // ever goes green, the repertoire table and the fonts that shipped describe different builds. - ok(!f->IsGlyphInFont(0x4E00), "U+4E00 is ABSENT (the instrument can still say no)"); + // Under a script a system face draws, U+4E00 is carried on purpose; the red case then probes a + // codepoint no source carries, embedded or system (Ugaritic, measured on the rig's faces: Ethiopic + // U+1200 was carried by one). + if (g_allowed) ok(!f->IsGlyphInFont(0x10380), "U+10380 is ABSENT (the instrument can still say no)"); + else ok(!f->IsGlyphInFont(0x4E00), "U+4E00 is ABSENT (the instrument can still say no)"); // The negative controls: the exclude mechanism asserted from the config end, which the // superset invariant cannot reach, since it only fires once something draws an offending @@ -163,6 +225,14 @@ void RunSelftest(ImFontAtlas* atlas, ImTextureData* tex) { int badSources = 0, firstBad = -1; for (int s = 0; s < atlas->Sources.Size; ++s) { const ImWchar* list = atlas->Sources[s].GlyphExcludeRanges; + // Equality, per source: the system face against its own list, every other against the + // generated table -- so an embedded face handed the system list (all of Latin refused) is a + // failure under every language. + const bool system = std::strcmp(atlas->Sources[s].Name, kSystemSourceName) == 0; + const std::vector sys = system ? SystemExpected() + : std::vector(); + const coop::text::CodepointRange* want = system ? sys.data() : ranges; + const size_t wantN = system ? sys.size() : nRanges; bool good = (list != nullptr); if (good) { // Length first, walking the list's own terminator, so a short list is never read past @@ -170,10 +240,10 @@ void RunSelftest(ImFontAtlas* atlas, ImTextureData* tex) { // static assertion forbids a leading U+0000 and every end is at least its begin. size_t len = 0; while (list[len] != 0) ++len; - good = (len == nRanges * 2); - for (size_t i = 0; good && i < nRanges; ++i) - good = list[i * 2] == static_cast(ranges[i].begin) && - list[i * 2 + 1] == static_cast(ranges[i].end); + good = (len == wantN * 2); + for (size_t i = 0; good && i < wantN; ++i) + good = list[i * 2] == static_cast(want[i].begin) && + list[i * 2 + 1] == static_cast(want[i].end); } if (!good && ++badSources == 1) firstBad = s; } @@ -208,6 +278,22 @@ void RunSelftest(ImFontAtlas* atlas, ImTextureData* tex) { } ok(nonGrey > 0, "U+1F600's atlas box holds non-greyscale texels (it is COLOURED)"); + // The CJK arm, under a script a system face draws: the face carries U+4E00 and a deliberate bake + // of it has pixels and comes from that face; the blanks the table refuses and the ideographic zero + // the allowance leaves out are taken by no source; Latin still comes from an embedded face. + if (g_allowed) { + ok(f->IsGlyphInFont(0x4E00), "U+4E00 is carried (the system face)"); + const ImFontGlyph* han = baked ? baked->FindGlyphNoFallback(static_cast(0x4E00)) : nullptr; + ok(han && han->Visible, "U+4E00 bakes with pixels at the nameplate size"); + ok(han && SourceNamed(f, han->SourceIdx, kSystemSourceName), "U+4E00 comes from the system face"); + ok(!SomeSourceTakes(f, 0x3000) && !SomeSourceTakes(f, 0x3007) && !SomeSourceTakes(f, 0xFF41), + "U+3000, U+3007 and U+FF41 are taken by no source"); + const ImFontGlyph* latin = baked ? baked->FindGlyphNoFallback(static_cast('A')) : nullptr; + ok(latin && !SourceNamed(f, latin->SourceIdx, kSystemSourceName), "'A' comes from an embedded face"); + } else { + UE_LOGI("font selftest: no CJK script is active -- the CJK arm is skipped"); + } + // A positive line carrying its counts, and that shape is load-bearing: the smoke asserted this // selftest by grepping for the absence of a failure line, which is sound only while the // selftest runs unconditionally at boot. It is conditional now, fired on a texture-id edge, so @@ -224,6 +310,11 @@ void RunSelftest(ImFontAtlas* atlas, ImTextureData* tex) { } // namespace +void AllowScript(const coop::text::CodepointRange* ranges, size_t count) { + g_allowed = ranges; + g_allowedCount = ranges ? count : 0; +} + void OnContextDestroyed() { g_checkedTexId = 0; g_logW = g_logH = 0; diff --git a/src/votv-coop/src/ui/fonts.cpp b/src/votv-coop/src/ui/fonts.cpp index 646811cfe..efa984413 100644 --- a/src/votv-coop/src/ui/fonts.cpp +++ b/src/votv-coop/src/ui/fonts.cpp @@ -9,6 +9,7 @@ #include "coop/config/config_registry.h" #include "coop/text/repertoire.h" #include "l10n/l10n.h" +#include "ui/atlas_watch.h" #include "ui/scale.h" #include "ue_wrap/core/log.h" @@ -17,6 +18,8 @@ #include +#include +#include #include #include #include @@ -128,7 +131,9 @@ const void* ResourceTtf(int id, int* outSize) { // The exclude set (coop/text/repertoire.h) in ImGui's range form: a coarse cover of what // our faces carry, of the table the nickname arbiter folds against. One generator emits both, -// so what is refused for baking and what folds to the sentinel cannot drift. Subtractive +// so what is refused for baking and what folds to the sentinel cannot drift -- for every face but +// the system face merged for the active language's script (MergeSystemFace below), which bakes that +// script on purpose while the fold still reads it as the sentinel. Subtractive // because the lazy atlas ignores an inclusion list and bakes whatever is drawn; the only lever // is the per-source exclude list. The list is zero-terminated, so it may not begin with U+0000 // (ImGui's walk would stop at index 0 and exclude nothing, with no symptom); the generator @@ -158,6 +163,124 @@ const ImWchar* ExcludeList() { return v.data(); } +// ---- the script a pack needs and our faces do not carry --------------------------------------- +// The active language's script and the Windows face that draws it -- #41's merge, restricted. The +// face is not ours, so it is held to its script: its own exclude list is the generated table united +// with everything outside the script's ranges, and it is merged last, so it supplies only what every +// embedded face lacked inside its script and Latin, Cyrillic and every symbol still come from ours. +// The allowance leaves out what looks like a Latin character while folding apart from it (U+3007, the +// fullwidth digits and letters), and the generated table keeps U+3000 and U+FFA0 refused here too. +// Only Simplified Chinese has a row: the one pack that ships; another script is one row. +struct ScriptDesc { + const char* id; + const coop::text::CodepointRange* ranges; // sorted + size_t count; + const char* const* faces; // first found wins; null-terminated +}; +constexpr coop::text::CodepointRange kHansRanges[] = { + {0x3001, 0x3006}, {0x3008, 0x303F}, {0x3400, 0x4DBF}, {0x4E00, 0x9FFF}, {0xF900, 0xFAFF}, + {0xFF01, 0xFF0F}, {0xFF1A, 0xFF20}, {0xFF3B, 0xFF40}, {0xFF5B, 0xFF65}, +}; +constexpr const char* kHansFaces[] = {"msyh.ttc", "msyhl.ttc", "simhei.ttf", "simsun.ttc", nullptr}; +constexpr ScriptDesc kHans = {"Hans", kHansRanges, sizeof(kHansRanges) / sizeof(kHansRanges[0]), kHansFaces}; + +// The script of the language whose catalogue loaded. +const ScriptDesc* ActiveScript() { + const std::string loc = l10n::ActiveLocale(); + if (loc == "zh" || loc == "zh_CN" || loc == "zh_SG") return &kHans; + return nullptr; +} + +// The face's bytes, read once per process and held while it lives: every atlas rebuild adds it from +// memory, not owned by the atlas, as the embedded faces are added; a file add would read and hold it +// once per role on every rebuild. A missing face is said once and its script draws as boxes. +struct SystemFace { + bool tried = false; + std::vector bytes; + std::string name; +}; +const SystemFace& LoadSystemFace(const ScriptDesc& script) { + static SystemFace face; + if (face.tried) return face; + face.tried = true; + char windir[MAX_PATH] = {}; + ::GetWindowsDirectoryA(windir, sizeof(windir)); + const std::string dir = windir[0] ? std::string(windir) + "\\Fonts\\" : std::string(); + for (const char* const* f = script.faces; *f && !dir.empty(); ++f) { + HANDLE h = ::CreateFileA((dir + *f).c_str(), GENERIC_READ, FILE_SHARE_READ, nullptr, OPEN_EXISTING, + FILE_ATTRIBUTE_NORMAL, nullptr); + if (h == INVALID_HANDLE_VALUE) continue; + LARGE_INTEGER size{}; + if (::GetFileSizeEx(h, &size) && size.QuadPart > 0 && size.QuadPart < (64LL << 20)) { + face.bytes.resize(static_cast(size.QuadPart)); + DWORD got = 0; + if (!::ReadFile(h, face.bytes.data(), static_cast(face.bytes.size()), &got, nullptr) || + got != face.bytes.size()) + face.bytes.clear(); + } + ::CloseHandle(h); + if (!face.bytes.empty()) { + face.name = *f; + break; + } + } + if (face.bytes.empty()) + UE_LOGW("fonts: the %s script needs a Windows face and none of its candidates is in '%s' -- its " + "characters draw as boxes", script.id, dir.c_str()); + else + UE_LOGI("fonts: %s script -- the Windows face '%s' (%zu bytes) merged into every role, restricted " + "to the script", script.id, face.name.c_str(), face.bytes.size()); + return face; +} + +// The system face's exclude list: the generated table united with the complement of the script's +// ranges over U+0001..U+10FFFF (never beginning at U+0000: the list is zero-terminated), merged and +// sorted. Built once; under the no-exclude drill it is null, as every source's is. +const ImWchar* SystemExcludeList(const ScriptDesc& script) { + if (!ExcludeList()) return nullptr; + static std::vector v; + if (!v.empty()) return v.data(); + size_t n = 0; + const coop::text::CodepointRange* gen = coop::text::ExcludeRanges(&n); + std::vector all(gen, gen + n); + uint32_t next = 1; + for (size_t i = 0; i < script.count; ++i) { + if (script.ranges[i].begin > next) all.push_back({next, script.ranges[i].begin - 1}); + next = script.ranges[i].end + 1; + } + if (next <= 0x10FFFF) all.push_back({next, 0x10FFFF}); + std::sort(all.begin(), all.end(), [](const auto& x, const auto& y) { return x.begin < y.begin; }); + std::vector merged; + for (const auto& r : all) { + if (!merged.empty() && r.begin <= merged.back().end + 1) merged.back().end = (std::max)(merged.back().end, r.end); + else merged.push_back(r); + } + for (const auto& r : merged) { + v.push_back(static_cast(r.begin)); + v.push_back(static_cast(r.end)); + } + v.push_back(0); + return v.data(); +} + +// Merged into the face just added, after the colour donor, so ImGui's walk of the sources reaches it +// only for what every embedded face lacked. Named, so the watcher's exclude check knows it, and the +// watcher is told the script it may bake. +void MergeSystemFace(float px) { + const ScriptDesc* script = ActiveScript(); + if (!script) return; + const SystemFace& face = LoadSystemFace(*script); + if (face.bytes.empty()) return; + ImFontConfig cfg; + cfg.MergeMode = true; + cfg.FontDataOwnedByAtlas = false; + cfg.GlyphExcludeRanges = SystemExcludeList(*script); + std::snprintf(cfg.Name, sizeof(cfg.Name), "%s", ui::atlas_watch::kSystemSourceName); + ImGui::GetIO().Fonts->AddFontFromMemoryTTF(const_cast(face.bytes.data()), + static_cast(face.bytes.size()), px, &cfg, nullptr); + ui::atlas_watch::AllowScript(script->ranges, script->count); +} + // Every add goes through this funnel or AddFromFile, and the exclude list is applied here // rather than at each config declaration: four configs are built, and a fifth added later // would otherwise silently bake the whole cmap. @@ -195,6 +318,7 @@ void MergeBackstops(int chosenFamily, bool bold, float px) { // The flag still does the job under per-size baking. donor.FontLoaderFlags |= ImGuiFreeTypeLoaderFlags_LoadColor; AddFromResource(IDR_FONT_EMOJI_DONOR, px, donor); + MergeSystemFace(px); } ImFont* AddFromFile(const std::string& path, float px, const ImFontConfig& baseCfg) { From 263db400232c423e115552e6380bd9a073ae0a92 Mon Sep 17 00:00:00 2001 From: pelmentor Date: Sat, 10 Oct 2026 08:19:11 +0600 Subject: [PATCH 098/306] [ui] the script face is mapped, and its arm runs face or no face The Chinese face is mapped read-only and its view held, not read into a 20 MB private copy on the render thread; a candidate that exists and does not open or map is named with its reason. Each script row carries its own face and exclude list, and the row's order is asserted. Every Load tells the watcher the script before any face is added, so a zh run with no face, or on a path that merges none, fails its CJK arm instead of skipping it. The red case under a script probes U+1FFFF, a noncharacter absent from every candidate face measured; the cheaper exclude test runs first; comments made false are corrected. Co-Authored-By: Claude Opus 5.5 --- src/votv-coop/include/coop/text/repertoire.h | 24 ++-- src/votv-coop/include/ui/atlas_watch.h | 5 +- src/votv-coop/src/ui/atlas_watch.cpp | 25 ++-- src/votv-coop/src/ui/fonts.cpp | 131 +++++++++++++------ 4 files changed, 121 insertions(+), 64 deletions(-) diff --git a/src/votv-coop/include/coop/text/repertoire.h b/src/votv-coop/include/coop/text/repertoire.h index f435c6f0b..306560a3d 100644 --- a/src/votv-coop/include/coop/text/repertoire.h +++ b/src/votv-coop/include/coop/text/repertoire.h @@ -11,7 +11,8 @@ // the authority over everyone else's name. It is generated from the fonts on disk, and the // ranges ui::fonts::Load bakes are emitted in the same run. The two fallback font paths bake // no embedded family and their atlas is short of this table, which the boot font selftest -// logs; the FOLD does not move there, so peers still agree. +// logs; a language pack whose script our faces lack has a Windows face bake that script beyond +// it (ui/fonts.cpp). The FOLD moves in neither case, so peers still agree. #pragma once #include @@ -31,19 +32,16 @@ struct CodepointRange { bool InRepertoire(uint32_t cp); // The codepoints we REFUSE to bake: a coarse cover holding everything the render set carries -// that InRepertoire omits, merged across unassigned space to stay under ImGui's list cap. +// that InRepertoire omits, merged across unassigned space to stay under ImGui's list cap. The atlas +// is lazy and ignores ImFontConfig::GlyphRanges, so the only lever is subtractive +// (GlyphExcludeRanges). One generator run emits both tables, so fold and bake stay one fact for +// every embedded face; the Windows face a pack's script needs takes this table united with all +// outside that script (ui/fonts.cpp), so it bakes the script while the fold reads it as the sentinel. // -// It exists because the atlas is lazy. ImGui bakes a codepoint the first time something draws -// it and ignores ImFontConfig::GlyphRanges, so the only surviving lever is subtractive: -// ImFontConfig::GlyphExcludeRanges, consulted on the on-demand path. One generator run emits -// both tables from one source set, so fold and bake stay one fact, maintained from the -// subtractive side. -// -// InExcludeSet is NOT `!InRepertoire`. The repertoire is what we can draw; the exclude set is -// what we refuse to draw. A codepoint no font carries at all is outside BOTH. The distinction -// is load-bearing for the pack-failure detector in ui/fonts.cpp: "the font has it, we did not -// forbid it, and it still failed to bake" is a defect, while "the font has it and we forbade -// it" is this table working. +// InExcludeSet is NOT `!InRepertoire`: the repertoire is what we can draw, the exclude set what we +// refuse to draw, and a codepoint no font carries is outside BOTH. "The font has it, we did not +// forbid it, and it still failed to bake" is a defect; "the font has it and we forbade it" is this +// table working. The pack-failure detector (ui/atlas_watch.cpp) asks that of each source's own list. bool InExcludeSet(uint32_t cp); // The exclude set as ranges, for the atlas builder. Sorted, non-overlapping, and diff --git a/src/votv-coop/include/ui/atlas_watch.h b/src/votv-coop/include/ui/atlas_watch.h index c199170c8..0f8d38a8b 100644 --- a/src/votv-coop/include/ui/atlas_watch.h +++ b/src/votv-coop/include/ui/atlas_watch.h @@ -27,8 +27,9 @@ inline constexpr const char kSystemSourceName[] = "multivoid:system"; // The script ranges that system face may bake: a baked glyph inside them is expected, not a superset // fault, and the exclude check holds the named source to the generated table united with their -// complement. Called by ui/fonts.cpp each time it merges the face, before the next OnFrame; the -// table is static and is kept across a context's destruction. Render thread. +// complement. Called by ui/fonts.cpp at each Load, before any face is added and on every path, so +// the selftest's script arm runs whether the face loaded or not (null when the language needs no +// script); the table is static and is kept across a context's destruction. Render thread. void AllowScript(const coop::text::CodepointRange* ranges, size_t count); // Call once per frame, INSIDE the frame (after ImGui::NewFrame). In-frame is required, not diff --git a/src/votv-coop/src/ui/atlas_watch.cpp b/src/votv-coop/src/ui/atlas_watch.cpp index a9ca92d7b..2d9360fbb 100644 --- a/src/votv-coop/src/ui/atlas_watch.cpp +++ b/src/votv-coop/src/ui/atlas_watch.cpp @@ -61,7 +61,7 @@ bool SourceCarries(ImFontAtlas* atlas, ImFontConfig* src, uint32_t cp) { // A source that has the codepoint and does not forbid it -- what a bake of it can come from. bool SomeSourceTakes(ImFont* font, uint32_t cp) { for (ImFontConfig* src : font->Sources) - if (SourceCarries(font->OwnerAtlas, src, cp) && !SourceExcludes(src, cp)) return true; + if (!SourceExcludes(src, cp) && SourceCarries(font->OwnerAtlas, src, cp)) return true; return false; } bool SourceNamed(ImFont* font, unsigned idx, const char* name) { @@ -183,9 +183,9 @@ void RunSelftest(ImFontAtlas* atlas, ImTextureData* tex) { // one. U+4E00 is the first CJK ideograph and no embedded face or donor carries it; if this // ever goes green, the repertoire table and the fonts that shipped describe different builds. // Under a script a system face draws, U+4E00 is carried on purpose; the red case then probes a - // codepoint no source carries, embedded or system (Ugaritic, measured on the rig's faces: Ethiopic - // U+1200 was carried by one). - if (g_allowed) ok(!f->IsGlyphInFont(0x10380), "U+10380 is ABSENT (the instrument can still say no)"); + // noncharacter, which Unicode never assigns, so no face maps it whichever candidate a machine has + // (measured absent from every embedded face, msyh, msyhl and simsun; Ethiopic U+1200 was carried). + if (g_allowed) ok(!f->IsGlyphInFont(0x1FFFF), "U+1FFFF is ABSENT (the instrument can still say no)"); else ok(!f->IsGlyphInFont(0x4E00), "U+4E00 is ABSENT (the instrument can still say no)"); // The negative controls: the exclude mechanism asserted from the config end, which the @@ -223,14 +223,14 @@ void RunSelftest(ImFontAtlas* atlas, ImTextureData* tex) { size_t nRanges = 0; const coop::text::CodepointRange* ranges = coop::text::ExcludeRanges(&nRanges); int badSources = 0, firstBad = -1; + const std::vector sys = g_allowed ? SystemExpected() + : std::vector(); for (int s = 0; s < atlas->Sources.Size; ++s) { const ImWchar* list = atlas->Sources[s].GlyphExcludeRanges; // Equality, per source: the system face against its own list, every other against the // generated table -- so an embedded face handed the system list (all of Latin refused) is a // failure under every language. const bool system = std::strcmp(atlas->Sources[s].Name, kSystemSourceName) == 0; - const std::vector sys = system ? SystemExpected() - : std::vector(); const coop::text::CodepointRange* want = system ? sys.data() : ranges; const size_t wantN = system ? sys.size() : nRanges; bool good = (list != nullptr); @@ -247,11 +247,11 @@ void RunSelftest(ImFontAtlas* atlas, ImTextureData* tex) { } if (!good && ++badSources == 1) firstBad = s; } - ok(badSources == 0, "every ImFontConfig carries the generated exclude table"); + ok(badSources == 0, "every ImFontConfig carries its expected exclude list"); if (badSources > 0) - UE_LOGE("font selftest: %d of %d source(s) lack the generated exclude table, first " - "index %d -- that config bakes its WHOLE cmap, and the fold table sentinels " - "every codepoint of it, so two legible names can collapse to one key.", + UE_LOGE("font selftest: %d of %d source(s) lack their expected exclude list, first " + "index %d -- that config bakes beyond what it was given, up to its WHOLE cmap, and the " + "fold table sentinels every such codepoint, so two legible names can collapse to one key.", badSources, atlas->Sources.Size, firstBad); // The one deliberate bake. @@ -282,6 +282,9 @@ void RunSelftest(ImFontAtlas* atlas, ImTextureData* tex) { // of it has pixels and comes from that face; the blanks the table refuses and the ideographic zero // the allowance leaves out are taken by no source; Latin still comes from an embedded face. if (g_allowed) { + bool merged = false; + for (ImFontConfig* src : f->Sources) merged = merged || std::strcmp(src->Name, kSystemSourceName) == 0; + ok(merged, "the script's Windows face is merged (a fonts warning names the candidates if not)"); ok(f->IsGlyphInFont(0x4E00), "U+4E00 is carried (the system face)"); const ImFontGlyph* han = baked ? baked->FindGlyphNoFallback(static_cast(0x4E00)) : nullptr; ok(han && han->Visible, "U+4E00 bakes with pixels at the nameplate size"); @@ -291,7 +294,7 @@ void RunSelftest(ImFontAtlas* atlas, ImTextureData* tex) { const ImFontGlyph* latin = baked ? baked->FindGlyphNoFallback(static_cast('A')) : nullptr; ok(latin && !SourceNamed(f, latin->SourceIdx, kSystemSourceName), "'A' comes from an embedded face"); } else { - UE_LOGI("font selftest: no CJK script is active -- the CJK arm is skipped"); + UE_LOGI("font selftest: the language needs no script beyond our faces -- the CJK arm is skipped"); } // A positive line carrying its counts, and that shape is load-bearing: the smoke asserted this diff --git a/src/votv-coop/src/ui/fonts.cpp b/src/votv-coop/src/ui/fonts.cpp index efa984413..28e455b9a 100644 --- a/src/votv-coop/src/ui/fonts.cpp +++ b/src/votv-coop/src/ui/fonts.cpp @@ -19,6 +19,7 @@ #include #include +#include #include #include #include @@ -171,18 +172,46 @@ const ImWchar* ExcludeList() { // The allowance leaves out what looks like a Latin character while folding apart from it (U+3007, the // fullwidth digits and letters), and the generated table keeps U+3000 and U+FFA0 refused here too. // Only Simplified Chinese has a row: the one pack that ships; another script is one row. +// +// Each row carries its own state, the face's view and the face's exclude list: the language is +// fixed for the process (l10n::Init runs once), so a row's state is built once and held while the +// process lives -- the atlas reads the view in place on every rebuild -- and never another row's. +struct SystemFace { + bool tried = false; + const char* data = nullptr; // a read-only view of the file, never unmapped + size_t size = 0; + std::string name; +}; +struct ScriptState { + SystemFace face; + std::vector exclude; +}; struct ScriptDesc { const char* id; - const coop::text::CodepointRange* ranges; // sorted + const coop::text::CodepointRange* ranges; // sorted and disjoint (SortedDisjoint) size_t count; const char* const* faces; // first found wins; null-terminated + ScriptState* state; }; +// Both exclude-list derivations (here and ui/atlas_watch.cpp) walk the ranges in order, and their +// equality check cannot see an input both read wrong, so the order is asserted where the row is. +template +constexpr bool SortedDisjoint(const coop::text::CodepointRange (&r)[N]) { + for (size_t i = 0; i < N; ++i) { + if (r[i].begin < 1 || r[i].begin > r[i].end || r[i].end > 0x10FFFF) return false; + if (i > 0 && r[i].begin <= r[i - 1].end) return false; + } + return true; +} constexpr coop::text::CodepointRange kHansRanges[] = { {0x3001, 0x3006}, {0x3008, 0x303F}, {0x3400, 0x4DBF}, {0x4E00, 0x9FFF}, {0xF900, 0xFAFF}, {0xFF01, 0xFF0F}, {0xFF1A, 0xFF20}, {0xFF3B, 0xFF40}, {0xFF5B, 0xFF65}, }; +static_assert(SortedDisjoint(kHansRanges), "a script's ranges must be sorted, disjoint and inside U+0001..U+10FFFF"); constexpr const char* kHansFaces[] = {"msyh.ttc", "msyhl.ttc", "simhei.ttf", "simsun.ttc", nullptr}; -constexpr ScriptDesc kHans = {"Hans", kHansRanges, sizeof(kHansRanges) / sizeof(kHansRanges[0]), kHansFaces}; +ScriptState g_hansState; +constexpr ScriptDesc kHans = {"Hans", kHansRanges, sizeof(kHansRanges) / sizeof(kHansRanges[0]), kHansFaces, + &g_hansState}; // The script of the language whose catalogue loaded. const ScriptDesc* ActiveScript() { @@ -191,16 +220,15 @@ const ScriptDesc* ActiveScript() { return nullptr; } -// The face's bytes, read once per process and held while it lives: every atlas rebuild adds it from -// memory, not owned by the atlas, as the embedded faces are added; a file add would read and hold it -// once per role on every rebuild. A missing face is said once and its script draws as boxes. -struct SystemFace { - bool tried = false; - std::vector bytes; - std::string name; -}; +// The face, mapped once per process and the view held while it lives: every atlas rebuild adds it from +// memory, not owned by the atlas, as the embedded faces are added (a file add would read it once per +// role on every rebuild), and FreeType reads only the pages it touches -- file-backed, so neither a +// 20 MB private copy nor a whole-file read on the render thread. The trade: a disk error under a held +// view faults inside FreeType where a read would have failed softly, on the local Windows directory. +// A candidate that exists and does not open or map is named with its reason; a missing face is said +// once and its script draws as boxes. const SystemFace& LoadSystemFace(const ScriptDesc& script) { - static SystemFace face; + SystemFace& face = script.state->face; if (face.tried) return face; face.tried = true; char windir[MAX_PATH] = {}; @@ -209,36 +237,52 @@ const SystemFace& LoadSystemFace(const ScriptDesc& script) { for (const char* const* f = script.faces; *f && !dir.empty(); ++f) { HANDLE h = ::CreateFileA((dir + *f).c_str(), GENERIC_READ, FILE_SHARE_READ, nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr); - if (h == INVALID_HANDLE_VALUE) continue; + if (h == INVALID_HANDLE_VALUE) { + const DWORD err = ::GetLastError(); + if (err != ERROR_FILE_NOT_FOUND && err != ERROR_PATH_NOT_FOUND) + UE_LOGW("fonts: the %s script's face '%s' is skipped -- it does not open (error %lu)", script.id, *f, + static_cast(err)); + continue; + } LARGE_INTEGER size{}; - if (::GetFileSizeEx(h, &size) && size.QuadPart > 0 && size.QuadPart < (64LL << 20)) { - face.bytes.resize(static_cast(size.QuadPart)); - DWORD got = 0; - if (!::ReadFile(h, face.bytes.data(), static_cast(face.bytes.size()), &got, nullptr) || - got != face.bytes.size()) - face.bytes.clear(); + const void* view = nullptr; + const char* why = nullptr; + if (!::GetFileSizeEx(h, &size) || size.QuadPart <= 0) { + why = "its size does not read"; + } else if (size.QuadPart > INT_MAX) { + why = "larger than the atlas takes (an int size)"; + } else if (HANDLE m = ::CreateFileMappingA(h, nullptr, PAGE_READONLY, 0, 0, nullptr)) { + view = ::MapViewOfFile(m, FILE_MAP_READ, 0, 0, 0); + ::CloseHandle(m); // the view keeps the mapping, and the file, open + if (!view) why = "its view does not map"; + } else { + why = "it does not map"; } ::CloseHandle(h); - if (!face.bytes.empty()) { - face.name = *f; - break; + if (why) { + UE_LOGW("fonts: the %s script's face '%s' is skipped -- %s", script.id, *f, why); + continue; } + face.data = static_cast(view); + face.size = static_cast(size.QuadPart); + face.name = *f; + break; } - if (face.bytes.empty()) - UE_LOGW("fonts: the %s script needs a Windows face and none of its candidates is in '%s' -- its " - "characters draw as boxes", script.id, dir.c_str()); + if (!face.data) + UE_LOGW("fonts: the %s script needs a Windows face and none of its candidates mapped from '%s' -- " + "its characters draw as boxes", script.id, dir.empty() ? "(no Windows directory)" : dir.c_str()); else - UE_LOGI("fonts: %s script -- the Windows face '%s' (%zu bytes) merged into every role, restricted " - "to the script", script.id, face.name.c_str(), face.bytes.size()); + UE_LOGI("fonts: %s script -- the Windows face '%s' (%zu bytes, mapped) merged into every role, " + "restricted to the script", script.id, face.name.c_str(), face.size); return face; } // The system face's exclude list: the generated table united with the complement of the script's // ranges over U+0001..U+10FFFF (never beginning at U+0000: the list is zero-terminated), merged and -// sorted. Built once; under the no-exclude drill it is null, as every source's is. +// sorted. Built once per row; under the no-exclude drill it is null, as every source's is. const ImWchar* SystemExcludeList(const ScriptDesc& script) { if (!ExcludeList()) return nullptr; - static std::vector v; + std::vector& v = script.state->exclude; if (!v.empty()) return v.data(); size_t n = 0; const coop::text::CodepointRange* gen = coop::text::ExcludeRanges(&n); @@ -264,21 +308,20 @@ const ImWchar* SystemExcludeList(const ScriptDesc& script) { } // Merged into the face just added, after the colour donor, so ImGui's walk of the sources reaches it -// only for what every embedded face lacked. Named, so the watcher's exclude check knows it, and the -// watcher is told the script it may bake. +// only for what every embedded face lacked. Named, so the watcher's exclude check knows it. void MergeSystemFace(float px) { const ScriptDesc* script = ActiveScript(); if (!script) return; const SystemFace& face = LoadSystemFace(*script); - if (face.bytes.empty()) return; + if (!face.data) return; ImFontConfig cfg; cfg.MergeMode = true; cfg.FontDataOwnedByAtlas = false; cfg.GlyphExcludeRanges = SystemExcludeList(*script); std::snprintf(cfg.Name, sizeof(cfg.Name), "%s", ui::atlas_watch::kSystemSourceName); - ImGui::GetIO().Fonts->AddFontFromMemoryTTF(const_cast(face.bytes.data()), - static_cast(face.bytes.size()), px, &cfg, nullptr); - ui::atlas_watch::AllowScript(script->ranges, script->count); + // Read-only: with FontDataOwnedByAtlas off neither ImGui nor FreeType writes the data or frees it. + ImGui::GetIO().Fonts->AddFontFromMemoryTTF(const_cast(face.data), static_cast(face.size), px, + &cfg, nullptr); } // Every add goes through this funnel or AddFromFile, and the exclude list is applied here @@ -371,6 +414,13 @@ void Load() { io.Fonts->Clear(); for (int r = 0; r < kRoleCount; ++r) { g_roleFont[r] = nullptr; g_rolePx[r] = 0.f; } + // The watcher is told the active language's script before any face is added, on every path below + // and face or no face: its selftest arm then runs under every script a pack needs, and a face + // that did not load (or a path that merges none) is a failure there, not a skipped arm. + { + const ScriptDesc* script = ActiveScript(); + ui::atlas_watch::AllowScript(script ? script->ranges : nullptr, script ? script->count : 0); + } ReadRoleFamiliesOnce(); // Baked at the real pixel size for the live resolution, never through the global font scale, @@ -381,10 +431,15 @@ void Load() { // is allowed. Two reasons for 2048: the DX12 backend uploads the dirty bounding box through a // staging buffer that only grows, behind an untimed wait, and at 2048 the worst upload is 16.8 // MB (67 MB at 4096); and the atlas keeps the old and new texture across a repack, so the peak - // is two textures, 33.6 MB here against 134 MB. Capacity is not the binding constraint: the - // pathological demand, every remote-text surface asking for the whole repertoire at its own - // size in one shared atlas, measures to about 86% of 2048 squared, which fits with 17% - // headroom. That is why the pack-failure detector in ui/atlas_watch.cpp is load-bearing. + // is two textures, 33.6 MB here against 134 MB. For the repertoire capacity is not the binding + // constraint: the pathological demand, every remote-text surface asking for the whole repertoire + // at its own size in one shared atlas, measures to about 86% of 2048 squared, which fits with 17% + // headroom. A pack's script is not bounded so: the system face bakes each ideograph the first time + // something draws it, and a baked drawn every frame (the chat) keeps every glyph it ever took, so + // peer text accumulates. The shipped pack's own lines fit in one baked at every scale (352 + // ideographs, at most 45% of the atlas at the largest, so three such bakeds there would not); how + // much peer text fills it is not yet measured. That is why the pack-failure detector in + // ui/atlas_watch.cpp is load-bearing. io.Fonts->TexMaxWidth = 2048; io.Fonts->TexMaxHeight = 2048; // The drill (dev.atlas_texmax_drill, 0 off): 256 starves the packer, which is how the From 534a8543e0e3e340e01ea941fbd6b9af08c77f26 Mon Sep 17 00:00:00 2001 From: pelmentor Date: Sat, 10 Oct 2026 09:03:23 +0600 Subject: [PATCH 099/306] [dev] l10n drill: an atlas arm measures how much CJK text fits Under a script face the 2048 atlas holds the pack's own lines, but a baked drawn every frame keeps each glyph it took, so peer text in the chat accumulates, and how much fills it was not measured. The arm, under the Chinese pack, pushes the CJK block into the chat a line at a time and asks the atlas watcher for a census after each (the watcher answers on its next frame, running the pack-failure scan for it), until a codepoint fails to pack or the block ends, and prints the count drawn with the packed surface, the chat role's size and the UI scale. Co-Authored-By: Claude Opus 5.5 --- .../coop/config/config_registry_rows_dev.inc | 5 +- src/votv-coop/include/coop/dev/l10n_drill.h | 19 +++--- src/votv-coop/include/ui/atlas_watch.h | 16 +++++ src/votv-coop/src/coop/dev/l10n_drill.cpp | 68 ++++++++++++++++++- src/votv-coop/src/ui/atlas_watch.cpp | 34 ++++++++++ 5 files changed, 127 insertions(+), 15 deletions(-) diff --git a/src/votv-coop/include/coop/config/config_registry_rows_dev.inc b/src/votv-coop/include/coop/config/config_registry_rows_dev.inc index 72aac8971..1b81edd76 100644 --- a/src/votv-coop/include/coop/config/config_registry_rows_dev.inc +++ b/src/votv-coop/include/coop/config/config_registry_rows_dev.inc @@ -193,9 +193,10 @@ CFG_ENUM(chat_drill, "chat_drill", "dev", "off", "off|history|seed|i18n|span", " "Developer: chat checks judged in memory -- history (the host), seed (the second joiner), i18n " "(every peer), span (every peer: a mid-line nick's span through the feed); each prints " "[CHAT-DRILL] PASS, FAIL or ABORT.") -CFG_ENUM(l10n_drill, "l10n_drill", "dev", "off", "off|translated|english", "VOTVCOOP_L10N_DRILL", +CFG_ENUM(l10n_drill, "l10n_drill", "dev", "off", "off|translated|english|atlas", "VOTVCOOP_L10N_DRILL", "Developer: the interface-language drill on each peer -- opens two F1 panes, waits for the join line, " - "then expects them translated or English; prints [L10N-DRILL] DONE or FAIL.") + "then expects them translated or English; atlas floods the chat with ideographs under the Chinese pack " + "and measures the font atlas; prints [L10N-DRILL] DONE or FAIL.") CFG_FLAG(chat_span_prefix, "chat_span_prefix", "dev", false, "VOTVCOOP_CHAT_SPAN_PREFIX", "Developer: keep a peer action's nick as a prefix wherever its sentence put it (the chat drill's span red).") CFG_FLAG(chat_drill_bad_nick, "chat_drill_bad_nick", "dev", false, "VOTVCOOP_CHAT_DRILL_BAD_NICK", diff --git a/src/votv-coop/include/coop/dev/l10n_drill.h b/src/votv-coop/include/coop/dev/l10n_drill.h index cb2d8fabf..903ef0812 100644 --- a/src/votv-coop/include/coop/dev/l10n_drill.h +++ b/src/votv-coop/include/coop/dev/l10n_drill.h @@ -1,16 +1,15 @@ // coop/dev/l10n_drill.h -- drill: the interface's language, judged on each peer (ini l10n_drill= -// off|translated|english / env VOTVCOOP_L10N_DRILL). +// off|translated|english|atlas / env VOTVCOOP_L10N_DRILL). // // Once this peer's world is up (the host: the client's), it opens the F1 menu on World > Rules and then -// Network > Stats, each time waiting until the render thread reports it DREW that pane, closes the menu, -// and waits until the chat feed holds the join line naming the other peer, in English or translated. -// Then it judges: the two subs' names as the tree draws them by whether their lookups found a -// translation (l10n::WasFound), the join line by which of its two forms the feed holds. `translated` -// passes when all three are translated; `english` when the interface is English and none is -- so the -// per-peer arm (an English host, a translated client) has a pass on both peers, and `translated` under -// an English language is the red. It prints a DONE or a FAIL line tagged [L10N-DRILL] naming the -// expectation, the locale and what was found or missing. Readiness throughout, never a clock: a run -// that never gets there ends on the rig's budget with no line. +// Network > Stats, each time waiting until the render thread DREW that pane, closes the menu, and waits +// until the chat feed holds the join line naming the other peer. It judges the two subs' names by +// whether their lookups found a translation (l10n::WasFound) and the join line by which of its forms the +// feed holds: `translated` passes when all three are translated, `english` when the interface is +// English and none is (the per-peer arm has a pass on both peers; `translated` under English is the +// red). `atlas` measures instead: under the Chinese pack it pushes the CJK block into the chat a line +// at a time, with an atlas census after each, until a codepoint fails to pack or the block ends. Each +// prints a DONE or FAIL line tagged [L10N-DRILL]. Readiness throughout, never a clock. #pragma once diff --git a/src/votv-coop/include/ui/atlas_watch.h b/src/votv-coop/include/ui/atlas_watch.h index 0f8d38a8b..d0c644d8e 100644 --- a/src/votv-coop/include/ui/atlas_watch.h +++ b/src/votv-coop/include/ui/atlas_watch.h @@ -18,6 +18,7 @@ #include "coop/text/repertoire.h" // CodepointRange #include +#include namespace ui::atlas_watch { @@ -44,6 +45,21 @@ void AllowScript(const coop::text::CodepointRange* ranges, size_t count); // source. void OnFrame(); +// The atlas's state for a drill on the game thread. RequestCensus asks, and the next frame the watcher +// runs answers, running the pack-failure scan over every baked for it; CensusFor is true once that +// frame ran and fills `out`. One request at a time: a drill asks again only after its answer. +struct Census { + int texW = 0, texH = 0; // the texture now + int maxW = 0, maxH = 0; // the ceiling it may grow to + int packedPx = 0; // the packer's packed surface + int failures = 0; // codepoints a source has, that we did not exclude, that did not bake + uint32_t firstFailure = 0; + float chatPx = 0.f; // the chat role's baked size, and the UI scale it came from: what the + float uiScale = 0.f; // packed surface depends on, so a count measured here scales to another +}; +uint32_t RequestCensus(); +bool CensusFor(uint32_t request, Census* out); + // The context (and therefore the atlas) is gone. Clears the per-build memo -- // ImFontAtlas::TexNextUniqueID restarts at 1 in the constructor, so a remembered // ID would match the NEW atlas's first texture and silently skip the only check diff --git a/src/votv-coop/src/coop/dev/l10n_drill.cpp b/src/votv-coop/src/coop/dev/l10n_drill.cpp index d2f85200e..8e7621347 100644 --- a/src/votv-coop/src/coop/dev/l10n_drill.cpp +++ b/src/votv-coop/src/coop/dev/l10n_drill.cpp @@ -10,6 +10,7 @@ #include "coop/session/player_handshake.h" #include "coop/text/utf8_codec.h" #include "l10n/l10n.h" +#include "ui/atlas_watch.h" #include "ui/dev_menu.h" #include "ui/imgui_overlay.h" @@ -22,19 +23,22 @@ namespace coop::dev::l10n_drill { namespace { -enum class Expect : uint8_t { Off, Translated, English }; +enum class Expect : uint8_t { Off, Translated, English, Atlas }; Expect ExpectNow() { static const Expect e = [] { const std::string v = coop::config::ResolveEnum(::coop::config_registry::rows::l10n_drill); - return v == "translated" ? Expect::Translated : v == "english" ? Expect::English : Expect::Off; + return v == "translated" ? Expect::Translated + : v == "english" ? Expect::English + : v == "atlas" ? Expect::Atlas + : Expect::Off; }(); return e; } const char* ExpectName() { return ExpectNow() == Expect::Translated ? "translated" : "english"; } -enum class Step : uint8_t { WaitWorld, OpenRules, WaitRules, OpenStats, WaitStats, WaitJoinLine, Done }; +enum class Step : uint8_t { WaitWorld, OpenRules, WaitRules, OpenStats, WaitStats, WaitJoinLine, Flood, Done }; Step g_step = Step::WaitWorld; bool g_menuOpened = false; @@ -99,6 +103,49 @@ void Judge(Line join) { found, kProbes, l10n::FoundCount()); } +// The atlas arm: the chat flooded with distinct ideographs, a line at a time, until a pack failure or +// the end of the block -- how much peer text the 2048 atlas holds under a script face, since the chat +// baked keeps every glyph it ever drew. After each line two censuses, so a frame has drawn the line +// before the second answers; a progress line each kFloodReportEvery ideographs. +constexpr uint32_t kFloodFirst = 0x4E00, kFloodLast = 0x9FFF; +constexpr uint32_t kFloodPerLine = 40; +constexpr uint32_t kFloodReportEvery = 2000; +uint32_t g_floodNext = kFloodFirst; +uint32_t g_floodRequest = 0; +bool g_floodSecond = false; + +void FloodStep() { + if (g_floodRequest == 0) { // the next line + std::wstring line; + for (uint32_t k = 0; k < kFloodPerLine && g_floodNext <= kFloodLast; ++k) + line.push_back(static_cast(g_floodNext++)); + coop::chat_feed::Push(line, coop::chat_feed::Keep::Transient); + g_floodRequest = ui::atlas_watch::RequestCensus(); + g_floodSecond = false; + return; + } + ui::atlas_watch::Census c; + if (!ui::atlas_watch::CensusFor(g_floodRequest, &c)) return; + if (!g_floodSecond) { + g_floodRequest = ui::atlas_watch::RequestCensus(); + g_floodSecond = true; + return; + } + g_floodRequest = 0; + const uint32_t drawn = g_floodNext - kFloodFirst; + const double full = c.maxW > 0 && c.maxH > 0 ? 100.0 * c.packedPx / (double(c.maxW) * c.maxH) : 0.0; + if (c.failures == 0 && g_floodNext <= kFloodLast) { + if (drawn % kFloodReportEvery < kFloodPerLine) + UE_LOGI("[L10N-DRILL] atlas: %u ideographs drawn, atlas %dx%d, packed %d px (%.1f%% of %dx%d), chat %.1f px " + "at scale %.2f", drawn, c.texW, c.texH, c.packedPx, full, c.maxW, c.maxH, c.chatPx, c.uiScale); + return; + } + g_step = Step::Done; + UE_LOGI("[L10N-DRILL] DONE expect=atlas locale=%s ideographs=%u atlas=%dx%d packed=%d px (%.1f%% of %dx%d) " + "failures=%d first=U+%04X chat=%.1fpx scale=%.2f", l10n::ActiveLocale(), drawn, c.texW, c.texH, c.packedPx, + full, c.maxW, c.maxH, c.failures, c.firstFailure, c.chatPx, c.uiScale); +} + } // namespace void Tick(coop::net::Session* session) { @@ -107,6 +154,16 @@ void Tick(coop::net::Session* session) { switch (g_step) { case Step::WaitWorld: if (isHost ? !session->IsSlotWorldReady(1) : !coop::net_pump::HasAnnouncedWorldReady()) return; + if (ExpectNow() == Expect::Atlas) { + if (std::strncmp(l10n::ActiveLocale(), "zh", 2) != 0) { + g_step = Step::Done; + UE_LOGW("[L10N-DRILL] FAIL expect=atlas locale=%s -- the arm floods ideographs and needs the " + "Chinese pack active", l10n::ActiveLocale()[0] ? l10n::ActiveLocale() : "-"); + return; + } + g_step = Step::Flood; + return; + } ui::imgui_overlay::SetVisible(true); g_menuOpened = true; g_step = Step::OpenRules; @@ -135,6 +192,9 @@ void Tick(coop::net::Session* session) { Judge(join); return; } + case Step::Flood: + FloodStep(); + return; case Step::Done: return; } @@ -143,6 +203,8 @@ void Tick(coop::net::Session* session) { void OnDisconnect() { CloseMenu(); g_step = Step::WaitWorld; + g_floodNext = kFloodFirst; + g_floodRequest = 0; } } // namespace coop::dev::l10n_drill diff --git a/src/votv-coop/src/ui/atlas_watch.cpp b/src/votv-coop/src/ui/atlas_watch.cpp index 2d9360fbb..7bebb3be8 100644 --- a/src/votv-coop/src/ui/atlas_watch.cpp +++ b/src/votv-coop/src/ui/atlas_watch.cpp @@ -4,11 +4,13 @@ #include "coop/text/repertoire.h" #include "ui/fonts.h" +#include "ui/scale.h" #include "ue_wrap/core/log.h" #include "imgui.h" #include "imgui_internal.h" // ImFontAtlasBuilder: the packer counters + BakedPool #include +#include #include #include @@ -28,6 +30,12 @@ constexpr uint32_t kEmojiProbe = 0x1F600; // GRINNING FACE -- the donor's, and // The per-build memo. 0 means no build checked yet; texture ids count from 1, so 0 can never // be a real id. int g_checkedTexId = 0; + +// A drill's census (RequestCensus): the request number asked by the game thread, the one the render +// thread last answered, and the answer, written before the answered number is published. +std::atomic g_censusAsked{0}; +std::atomic g_censusAnswered{0}; +Census g_census; // Geometry as last logged, so the line appears on change instead of per frame. int g_logW = 0, g_logH = 0; // The packer's discarded-surface counter as last seen; -1 means never sampled. @@ -313,6 +321,16 @@ void RunSelftest(ImFontAtlas* atlas, ImTextureData* tex) { } // namespace +uint32_t RequestCensus() { + return g_censusAsked.fetch_add(1, std::memory_order_acq_rel) + 1; +} + +bool CensusFor(uint32_t request, Census* out) { + if (g_censusAnswered.load(std::memory_order_acquire) < request) return false; + if (out) *out = g_census; + return true; +} + void AllowScript(const coop::text::CodepointRange* ranges, size_t count) { g_allowed = ranges; g_allowedCount = ranges ? count : 0; @@ -410,6 +428,22 @@ void OnFrame() { b->RectsPackedSurface, discarded, was); } + // A drill's census, on request: the same scan, run for the answer whatever the edges say. + const uint32_t asked = g_censusAsked.load(std::memory_order_acquire); + if (b && asked != g_censusAnswered.load(std::memory_order_relaxed)) { + Census c; + c.texW = tex ? tex->Width : 0; + c.texH = tex ? tex->Height : 0; + c.maxW = atlas->TexMaxWidth; + c.maxH = atlas->TexMaxHeight; + c.packedPx = b->RectsPackedSurface; + c.chatPx = ui::fonts::PxFor(ui::fonts::Role::Chat); + c.uiScale = ui::scale::Ui(); + for (int i = 0; i < b->BakedPool.Size; ++i) c.failures += ScanPackFailures(b->BakedPool[i], c.firstFailure); + g_census = c; + g_censusAnswered.store(asked, std::memory_order_release); + } + // The selftest, per build. Every repack mints a fresh texture with a new id, so this catches // boot, rescale, the font family switch and every grow, in one integer compare per frame. if (tex && tex->UniqueID != g_checkedTexId) { From 7db440f508042d688184e05695cbf3d633625479 Mon Sep 17 00:00:00 2001 From: pelmentor Date: Sat, 10 Oct 2026 10:52:28 +0600 Subject: [PATCH 100/306] [ui] the atlas ceiling under a script face is 4096 Measured by the l10n drill's atlas arm on both peers: the whole CJK block, 20,992 ideographs, packs into 67% of the 2048 atlas at a 15 px chat, about 133 px squared each; by area that is some 3,400 distinct ideographs at 4K's 45 px and 1,400 at the scale cap's 72, against the few thousand everyday Chinese uses, and a baked drawn every frame keeps every glyph it took. Under a script face the ceiling is now 4096, which the lazy atlas reaches only for a session that draws that many. The regime gate pins both named ceilings, and its drill still goes red. Co-Authored-By: Claude Opus 5.5 --- .github/ci/atlas_regime_gate.ps1 | 12 ++++++++---- src/votv-coop/src/ui/fonts.cpp | 24 +++++++++++++----------- 2 files changed, 21 insertions(+), 15 deletions(-) diff --git a/.github/ci/atlas_regime_gate.ps1 b/.github/ci/atlas_regime_gate.ps1 index db5644298..daed4fd36 100644 --- a/.github/ci/atlas_regime_gate.ps1 +++ b/.github/ci/atlas_regime_gate.ps1 @@ -126,10 +126,14 @@ function Invoke-Gate([hashtable]$mutate) { # --- 3. the atlas ceiling is EXPLICIT ------------------------------------ # ImGui defaults TexMax to 8192. Inheriting it raises the worst DX12 upload - # behind an unbounded fence wait from 16.8 MB to 268 MB. + # behind an unbounded fence wait from 16.8 MB to 268 MB. Two ceilings are + # pinned: 2048, and 4096 under a script face, whose peer text accumulates + # (measured by the l10n drill's atlas arm); both are named + # constants and both sides of the texture take them. $f = $text['src\votv-coop\src\ui\fonts.cpp'] - if ($f -notmatch 'TexMaxWidth\s*=\s*2048' -or $f -notmatch 'TexMaxHeight\s*=\s*2048') { - $fails += "fonts.cpp does not pin TexMaxWidth/TexMaxHeight to 2048 (the 8192 default would be inherited)" + if ($f -notmatch 'kAtlasMax\s*=\s*2048' -or $f -notmatch 'kAtlasMaxScript\s*=\s*4096' -or + $f -notmatch 'TexMaxWidth\s*=\s*io\.Fonts->TexMaxHeight\s*=\s*ActiveScript\(\)\s*\?\s*kAtlasMaxScript\s*:\s*kAtlasMax') { + $fails += "fonts.cpp does not pin TexMaxWidth/TexMaxHeight to 2048 and 4096 (the 8192 default would be inherited)" } if ($f -notmatch 'GlyphExcludeRanges') { $fails += "fonts.cpp no longer sets GlyphExcludeRanges -- every codepoint in every face would bake" @@ -167,7 +171,7 @@ if ($Drill) { want = 'no longer asserts the regime' } @{ name = 'TexMax inherited' mut = @{ 'src\votv-coop\src\ui\fonts.cpp' = { - param($t) $t -replace 'TexMaxWidth\s*=\s*2048', 'TexMaxWidth = 8192' } } + param($t) $t -replace 'kAtlasMax\s*=\s*2048', 'kAtlasMax = 8192' } } want = 'does not pin TexMaxWidth' } @{ name = 'exclude list a no-op' mut = @{ 'src\votv-coop\include\coop\text\exclude_ranges.inc' = { diff --git a/src/votv-coop/src/ui/fonts.cpp b/src/votv-coop/src/ui/fonts.cpp index 28e455b9a..7fe863324 100644 --- a/src/votv-coop/src/ui/fonts.cpp +++ b/src/votv-coop/src/ui/fonts.cpp @@ -213,6 +213,16 @@ ScriptState g_hansState; constexpr ScriptDesc kHans = {"Hans", kHansRanges, sizeof(kHansRanges) / sizeof(kHansRanges[0]), kHansFaces, &g_hansState}; +// The atlas ceilings (Load). A script face's text is not bounded as the repertoire is: it bakes each +// ideograph the first time something draws it, and a baked drawn every frame (the chat) keeps every +// glyph it took, so peer text accumulates. Measured by the l10n drill's atlas arm: the whole CJK block, +// 20,992 ideographs, packs into 67% of 2048 squared at a 15 px chat, about 133 px squared each; by area +// some 3,400 at 4K's 45 px and 1,400 at the scale cap's 72, against the few thousand everyday Chinese +// uses. So under a script face the ceiling is 4096, reached only by a session that draws that many, +// which alone pays the larger upload and repack peak. The pack-failure detector says when it fills. +constexpr int kAtlasMax = 2048; +constexpr int kAtlasMaxScript = 4096; + // The script of the language whose catalogue loaded. const ScriptDesc* ActiveScript() { const std::string loc = l10n::ActiveLocale(); @@ -431,17 +441,9 @@ void Load() { // is allowed. Two reasons for 2048: the DX12 backend uploads the dirty bounding box through a // staging buffer that only grows, behind an untimed wait, and at 2048 the worst upload is 16.8 // MB (67 MB at 4096); and the atlas keeps the old and new texture across a repack, so the peak - // is two textures, 33.6 MB here against 134 MB. For the repertoire capacity is not the binding - // constraint: the pathological demand, every remote-text surface asking for the whole repertoire - // at its own size in one shared atlas, measures to about 86% of 2048 squared, which fits with 17% - // headroom. A pack's script is not bounded so: the system face bakes each ideograph the first time - // something draws it, and a baked drawn every frame (the chat) keeps every glyph it ever took, so - // peer text accumulates. The shipped pack's own lines fit in one baked at every scale (352 - // ideographs, at most 45% of the atlas at the largest, so three such bakeds there would not); how - // much peer text fills it is not yet measured. That is why the pack-failure detector in - // ui/atlas_watch.cpp is load-bearing. - io.Fonts->TexMaxWidth = 2048; - io.Fonts->TexMaxHeight = 2048; + // is two textures, 33.6 MB here against 134 MB. The repertoire's pathological demand, every + // remote-text surface asking for all of it at its own size, measures to about 86% of 2048 squared. + io.Fonts->TexMaxWidth = io.Fonts->TexMaxHeight = ActiveScript() ? kAtlasMaxScript : kAtlasMax; // The drill (dev.atlas_texmax_drill, 0 off): 256 starves the packer, which is how the // pack-failure detector is shown red. static const long s_texmaxDrill = From c48e37ce5e21a6224270478364216eac6a4671c4 Mon Sep 17 00:00:00 2001 From: pelmentor Date: Sat, 10 Oct 2026 11:11:05 +0600 Subject: [PATCH 101/306] [ui] one predicate for a script face, and the ceiling's comments true The lens on the atlas arm and the 4096 ceiling: four comments and the texmax drill row still named 2048 alone, and the regime gate's drill turned only the 2048 pin red. Those say both ceilings now, and the drill turns the 4096 pin and the script ternary red as well. The l10n drill asks ui::fonts::ScriptFaceActive, the predicate the ceiling uses, instead of its own locale prefix, and its count is named pushed. Co-Authored-By: Claude Opus 5.5 --- .github/ci/atlas_regime_gate.ps1 | 8 ++++++++ .../include/coop/config/config_registry_rows_dev.inc | 2 +- src/votv-coop/include/ui/fonts.h | 5 +++++ src/votv-coop/src/coop/dev/l10n_drill.cpp | 11 ++++++----- src/votv-coop/src/ui/fonts.cpp | 9 ++++++--- src/votv-coop/src/ui/overlay_backend_dx12.cpp | 2 +- 6 files changed, 27 insertions(+), 10 deletions(-) diff --git a/.github/ci/atlas_regime_gate.ps1 b/.github/ci/atlas_regime_gate.ps1 index daed4fd36..2cdc2c8b2 100644 --- a/.github/ci/atlas_regime_gate.ps1 +++ b/.github/ci/atlas_regime_gate.ps1 @@ -173,6 +173,14 @@ if ($Drill) { mut = @{ 'src\votv-coop\src\ui\fonts.cpp' = { param($t) $t -replace 'kAtlasMax\s*=\s*2048', 'kAtlasMax = 8192' } } want = 'does not pin TexMaxWidth' } + @{ name = 'script ceiling inherited' + mut = @{ 'src\votv-coop\src\ui\fonts.cpp' = { + param($t) $t -replace 'kAtlasMaxScript\s*=\s*4096', 'kAtlasMaxScript = 8192' } } + want = 'does not pin TexMaxWidth' } + @{ name = 'ceiling not chosen by the script' + mut = @{ 'src\votv-coop\src\ui\fonts.cpp' = { + param($t) $t -replace 'ActiveScript\(\)\s*\?\s*kAtlasMaxScript', 'false ? kAtlasMaxScript' } } + want = 'does not pin TexMaxWidth' } @{ name = 'exclude list a no-op' mut = @{ 'src\votv-coop\include\coop\text\exclude_ranges.inc' = { param($t) $t -replace '\{ 0x00001,', '{ 0x00000,' } } diff --git a/src/votv-coop/include/coop/config/config_registry_rows_dev.inc b/src/votv-coop/include/coop/config/config_registry_rows_dev.inc index 1b81edd76..80419074f 100644 --- a/src/votv-coop/include/coop/config/config_registry_rows_dev.inc +++ b/src/votv-coop/include/coop/config/config_registry_rows_dev.inc @@ -715,7 +715,7 @@ CFG_FLAG(force_save_churn, "force_save_churn", "dev", false, nullptr, // that does not. Both default off; neither is reachable from any UI. CFG_INT(atlas_texmax_drill, "atlas_texmax_drill", "dev", 0, 0, 8192, "VOTVCOOP_ATLAS_TEXMAX", - "Override the font atlas size ceiling (0 = the shipped 2048). Set 256 to starve " + "Override the font atlas size ceiling (0 = the shipped one: 2048, 4096 under a script face). Set 256 to starve " "the packer and prove the pack-failure detector fires.") // The admission drill, client side only, and that is the whole design: the host's gate has no // knob, no dev branch and no env check anywhere, so the drill exercises the real refusal or diff --git a/src/votv-coop/include/ui/fonts.h b/src/votv-coop/include/ui/fonts.h index 4fac26582..f6ef8624d 100644 --- a/src/votv-coop/include/ui/fonts.h +++ b/src/votv-coop/include/ui/fonts.h @@ -58,6 +58,11 @@ void Load(); ImFont* FontFor(Role r); float PxFor(Role r); +// Whether the active language needs a script our faces lack, drawn by a Windows face (its catalogue +// loaded and a script row names it). The one definition the atlas ceiling and the l10n drill share. +// Any thread once l10n::Init has run: the locale is fixed for the process. +bool ScriptFaceActive(); + const char* FamilyLabel(Family f); // "JetBrains Mono", ... const char* RoleLabel(Role r); // the role's name in the player's language ("Menu / panels" in English) diff --git a/src/votv-coop/src/coop/dev/l10n_drill.cpp b/src/votv-coop/src/coop/dev/l10n_drill.cpp index 8e7621347..2191de88d 100644 --- a/src/votv-coop/src/coop/dev/l10n_drill.cpp +++ b/src/votv-coop/src/coop/dev/l10n_drill.cpp @@ -11,6 +11,7 @@ #include "coop/text/utf8_codec.h" #include "l10n/l10n.h" #include "ui/atlas_watch.h" +#include "ui/fonts.h" #include "ui/dev_menu.h" #include "ui/imgui_overlay.h" @@ -104,7 +105,7 @@ void Judge(Line join) { } // The atlas arm: the chat flooded with distinct ideographs, a line at a time, until a pack failure or -// the end of the block -- how much peer text the 2048 atlas holds under a script face, since the chat +// the end of the block -- how much peer text the atlas holds under a script face, since the chat // baked keeps every glyph it ever drew. After each line two censuses, so a frame has drawn the line // before the second answers; a progress line each kFloodReportEvery ideographs. constexpr uint32_t kFloodFirst = 0x4E00, kFloodLast = 0x9FFF; @@ -141,7 +142,7 @@ void FloodStep() { return; } g_step = Step::Done; - UE_LOGI("[L10N-DRILL] DONE expect=atlas locale=%s ideographs=%u atlas=%dx%d packed=%d px (%.1f%% of %dx%d) " + UE_LOGI("[L10N-DRILL] DONE expect=atlas locale=%s pushed=%u atlas=%dx%d packed=%d px (%.1f%% of %dx%d) " "failures=%d first=U+%04X chat=%.1fpx scale=%.2f", l10n::ActiveLocale(), drawn, c.texW, c.texH, c.packedPx, full, c.maxW, c.maxH, c.failures, c.firstFailure, c.chatPx, c.uiScale); } @@ -155,10 +156,10 @@ void Tick(coop::net::Session* session) { case Step::WaitWorld: if (isHost ? !session->IsSlotWorldReady(1) : !coop::net_pump::HasAnnouncedWorldReady()) return; if (ExpectNow() == Expect::Atlas) { - if (std::strncmp(l10n::ActiveLocale(), "zh", 2) != 0) { + if (!ui::fonts::ScriptFaceActive()) { g_step = Step::Done; - UE_LOGW("[L10N-DRILL] FAIL expect=atlas locale=%s -- the arm floods ideographs and needs the " - "Chinese pack active", l10n::ActiveLocale()[0] ? l10n::ActiveLocale() : "-"); + UE_LOGW("[L10N-DRILL] FAIL expect=atlas locale=%s -- the arm floods ideographs and needs a " + "language whose script a Windows face draws", l10n::ActiveLocale()[0] ? l10n::ActiveLocale() : "-"); return; } g_step = Step::Flood; diff --git a/src/votv-coop/src/ui/fonts.cpp b/src/votv-coop/src/ui/fonts.cpp index 7fe863324..60e2bfb71 100644 --- a/src/votv-coop/src/ui/fonts.cpp +++ b/src/votv-coop/src/ui/fonts.cpp @@ -216,9 +216,9 @@ constexpr ScriptDesc kHans = {"Hans", kHansRanges, sizeof(kHansRanges) / sizeof( // The atlas ceilings (Load). A script face's text is not bounded as the repertoire is: it bakes each // ideograph the first time something draws it, and a baked drawn every frame (the chat) keeps every // glyph it took, so peer text accumulates. Measured by the l10n drill's atlas arm: the whole CJK block, -// 20,992 ideographs, packs into 67% of 2048 squared at a 15 px chat, about 133 px squared each; by area -// some 3,400 at 4K's 45 px and 1,400 at the scale cap's 72, against the few thousand everyday Chinese -// uses. So under a script face the ceiling is 4096, reached only by a session that draws that many, +// 20,992 ideographs, packs into 67% of 2048 squared at a 15 px chat, about 134 px squared each; by area, +// at full fill, about 3,500 at 4K's 45 px and 1,400 at the scale cap's 72, against the few thousand +// everyday Chinese uses. So under a script face the ceiling is 4096, reached only by a session that draws that many, // which alone pays the larger upload and repack peak. The pack-failure detector says when it fills. constexpr int kAtlasMax = 2048; constexpr int kAtlasMaxScript = 4096; @@ -417,6 +417,8 @@ bool BakeEmbeddedRoles(float s, const ImFontConfig& cfg) { } // namespace +bool ScriptFaceActive() { return ActiveScript() != nullptr; } + void Load() { ImGuiIO& io = ImGui::GetIO(); // Re-entrant: a scale or family change re-bakes the whole atlas. Clear drops the previous fonts @@ -443,6 +445,7 @@ void Load() { // MB (67 MB at 4096); and the atlas keeps the old and new texture across a repack, so the peak // is two textures, 33.6 MB here against 134 MB. The repertoire's pathological demand, every // remote-text surface asking for all of it at its own size, measures to about 86% of 2048 squared. + // Under a script face Load takes the dearer side of both reasons (kAtlasMaxScript, above). io.Fonts->TexMaxWidth = io.Fonts->TexMaxHeight = ActiveScript() ? kAtlasMaxScript : kAtlasMax; // The drill (dev.atlas_texmax_drill, 0 off): 256 starves the packer, which is how the // pack-failure detector is shown red. diff --git a/src/votv-coop/src/ui/overlay_backend_dx12.cpp b/src/votv-coop/src/ui/overlay_backend_dx12.cpp index 1649d72f6..e7785aea8 100644 --- a/src/votv-coop/src/ui/overlay_backend_dx12.cpp +++ b/src/votv-coop/src/ui/overlay_backend_dx12.cpp @@ -216,7 +216,7 @@ bool InitImguiBackend() { // agree about who collided while disagreeing about what the names look like. The // DX12-specific cost is upload, not correctness: every growth goes through UpdateTexture's // unbounded fence wait, which the probe below measures, and which is why the atlas ceiling is - // pinned at 2048 in ui/fonts.cpp. + // pinned in ui/fonts.cpp: 2048, and 4096 only under a script face, whose session pays that wait. return true; } From 4dc1478362f1998a55e3e4cb74f2ed6393fe19d9 Mon Sep 17 00:00:00 2001 From: pelmentor Date: Sat, 10 Oct 2026 14:21:38 +0600 Subject: [PATCH 102/306] [docs] credits: Slintchen's request is in, said as it landed The row and section described the translation layer as being rebuilt. They now say what landed: the Chinese pack under Slintchen's name as authored, and its conversion to the catalogues, the guide and the Chinese system face as our commits co-authored with Slintchen. Co-Authored-By: Claude Opus 5.5 --- docs/credits.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/credits.md b/docs/credits.md index 08b987671..59b798566 100644 --- a/docs/credits.md +++ b/docs/credits.md @@ -36,7 +36,7 @@ from `git shortlog -sne` and fold each person's identity variants together. | **Marlore** | code · report | A nine-symptom pass over the trash carry ([#29](https://github.com/VOTV-MP/Multivoid/pull/29)). Four were real defects and are fixed at the root, co-authored: a refused grab that was never answered, a pile that looked different on every peer (865 of 871 measured), a thrower refused every later grab, and a fallen player who kept carrying | 6 commits · unreleased | | [**Wigard**](https://github.com/wigarddev) | code | A guest of a sandbox host came up in story ([#32](https://github.com/VOTV-MP/Multivoid/pull/32)) — the transfer header had carried the host's game mode from the start and the host filled it with a constant. Then seven more, each measured red and green before it was sent: a build that did not link on VS 2022 ([#30](https://github.com/VOTV-MP/Multivoid/pull/30)), the shower's mirrored toggle running the wrong verb ([#31](https://github.com/VOTV-MP/Multivoid/pull/31)), a drive left frozen in the port on the far peer ([#33](https://github.com/VOTV-MP/Multivoid/pull/33)), a pile a client bags that the host never learns of ([#34](https://github.com/VOTV-MP/Multivoid/pull/34)), a client's upgrade purchase that charged nobody ([#35](https://github.com/VOTV-MP/Multivoid/pull/35)), a prop whose record changes in place telling no one ([#36](https://github.com/VOTV-MP/Multivoid/pull/36)), the bay window's sponge dabs reaching no other peer ([#37](https://github.com/VOTV-MP/Multivoid/pull/37)), and a client's press of the drone console reaching a drone that cannot fly ([#38](https://github.com/VOTV-MP/Multivoid/pull/38)). Then a disc the host ejects that reached the others blank, with the disc drill that proves it ([#39](https://github.com/VOTV-MP/Multivoid/pull/39)), and a desk repaint whose diagnosis matched a fix main already carried ([#40](https://github.com/VOTV-MP/Multivoid/pull/40)) | 13 commits · unreleased | | [**Shinobu2**](https://github.com/Shinobu2) | code | About twenty lanes in one request ([#45](https://github.com/VOTV-MP/Multivoid/pull/45)), adopted lane by lane: a newer save record replacing a parked one, a thrown container that kept its contents, damage and fire refused on a peer's puppet with its flame shown, the story-event creatures mirrored, an event fire crossing at the verb it ran in, a client's point sack paid by the host, the server repair's reward, the runtime ATV spawn, a joiner's daily task and nine more | 23 commits · unreleased | -| [**Slintchen**](https://github.com/Slintchen) | code | The request that the mod's UI speak the player's language ([#41](https://github.com/VOTV-MP/Multivoid/pull/41)): its Chinese translations, its system-face merge for CJK and its translator's guide carry into the translation layer being built | in progress | +| [**Slintchen**](https://github.com/Slintchen) | code | The request that the mod's UI speak the player's language ([#41](https://github.com/VOTV-MP/Multivoid/pull/41)): its Chinese pack, in as authored, and its translator's guide and system-face merge for CJK, carried into the translation layer with Slintchen as co-author | 1 commit, 2 co-authored · unreleased | | [**archhn0madd**](https://github.com/archhn0madd) | code | Rejoin without a relaunch — the boot poll answered from the dying world | 1 commit | | **Moddy** | review · design | The architecture and documentation review that became the UE4SS move; the public UE-Modding-Tools pointer that became the blueprint-CFG rung and the migration scanner (patternsleuth); and design published for [Relay](https://github.com/modestimpala/Relay), Moddy's Blueprint networking API for VOTV ([Thunderstore](https://thunderstore.io/c/voices-of-the-void/p/Moddy/Relay/)), in its README and the [README Blueprint](https://blueprintue.com/blueprint/g3s09x9c/) that README links: the watch surface on a Blueprint function, a watch that reads the parameters before the call and can cancel it, and one that only observes after it, which became the script-body gate; the readable join reason and stable diagnostic codes, which became the join screen's named steps and the end-reason codes; the list of cheap edge protections, of which two were missing here: a per-source limit on connections and a private access list on the identity key file; the rule columns that make an actor's own save record its spawn payload, the general form of what this project's prop save-data work was building case by case; the client-only `Quiesce` column, which made this project state its parking rule once and read every park against it; the one paragraph on container handling, which made it write down and measure its own container invariants; the note that a watch on a parent class misses a child's override, which sent us to audit every hook we install, which found three seams that had never installed and two verbs called on the wrong class; and the list of what a returning player's profile covers, which is the list this project's per-player profile now carries | b122 · b143 · 2026-09-02 · b153 · b157 · b160 · b161 · b167 | | **SentientYeet** | review | The substrate critique that re-opened the loader decision | b143 | @@ -178,10 +178,10 @@ Community commits are adopted with their **original authorship preserved** ### Slintchen - **The mod's UI in the player's language** ([#41](https://github.com/VOTV-MP/Multivoid/pull/41)): a layer that lets a translator ship a language without a rebuild, with a finished Chinese pack, a system-face merge - so CJK text renders, and a guide for translators. The layer is being rebuilt on gettext catalogues with - contexts and plural forms, and with only the text a player reads wrapped (the request had also wrapped - engine names a reflected call looks up); the translations, the face merge and the guide carry into it under - Slintchen's name. + so CJK text renders, and a guide for translators. The layer was rebuilt on gettext catalogues with contexts + and plural forms, wrapping only the text a player reads (the request had also wrapped engine names a + reflected call looks up). The Chinese pack is in under Slintchen's name as authored; its conversion to the + catalogues, the guide and the Chinese system face are commits of ours co-authored with Slintchen. ### archhn0madd - **Rejoin without a full relaunch** — the fix for SirWilliam's report below. From 5af39a52bf1b2959a7cdf502072a595a42248b35 Mon Sep 17 00:00:00 2001 From: pelmentor Date: Sat, 10 Oct 2026 14:55:08 +0600 Subject: [PATCH 103/306] [build] the developer drills and probes get a source list of their own sources.cmake passed the 800-line cap at 806 once the translation layer's units joined the drills' (the prose gate's over_800 counter refused it). The 150 units of src/coop/dev/ -- instruments a run arms by a [dev] row or a drill name, not the mod's lanes -- move to sources_dev.cmake, which sources.cmake includes and appends: 660 and 158 lines. Co-Authored-By: Claude Opus 5.5 --- src/votv-coop/sources.cmake | 156 +------------------------------ src/votv-coop/sources_dev.cmake | 158 ++++++++++++++++++++++++++++++++ 2 files changed, 163 insertions(+), 151 deletions(-) create mode 100644 src/votv-coop/sources_dev.cmake diff --git a/src/votv-coop/sources.cmake b/src/votv-coop/sources.cmake index f2bb1d249..be3e5afe3 100644 --- a/src/votv-coop/sources.cmake +++ b/src/votv-coop/sources.cmake @@ -7,7 +7,8 @@ # # Order follows the principle-7 split -- bootstrap and loader, then src/ue_wrap/ (engine # wrapper), then src/coop/ (gameplay and network), then src/ui/ and src/harness/, with the -# resource scripts last. +# resource scripts last. The developer drills and probes of src/coop/dev/ are a list of their +# own (sources_dev.cmake), appended at the end. set(VOTVCOOP_SOURCES src/bootstrap/dllmain.cpp @@ -287,7 +288,6 @@ set(VOTVCOOP_SOURCES src/coop/props/snapshot_census.cpp src/coop/props/save_identity_map.cpp src/coop/props/save_identity_bind.cpp - src/coop/dev/force_overdestroy_test.cpp src/coop/props/prop_census.cpp src/coop/props/prop_element_tracker.cpp src/coop/props/prop_key_index.cpp @@ -470,12 +470,6 @@ set(VOTVCOOP_SOURCES src/coop/props/container_slice_wire.cpp src/coop/props/container_view_close.cpp src/coop/props/container_write_policy.cpp - src/coop/dev/delivery_census_probe.cpp - src/coop/dev/store_table_probe.cpp - src/coop/dev/order_selftest.cpp - src/coop/dev/meadow_selftest.cpp - src/coop/dev/meadow_selftest_rows.cpp - src/coop/dev/order_probe.cpp src/ue_wrap/actors/inventory.cpp src/ue_wrap/hotbar/icons.cpp src/coop/items/hotbar_icon_edge.cpp @@ -496,152 +490,9 @@ set(VOTVCOOP_SOURCES src/coop/net/session_voice.cpp src/ui/voice_icons.cpp src/ui/voice_panel.cpp - src/coop/dev/add_points.cpp - src/coop/dev/spawn_order_probe.cpp - src/coop/dev/prop_birth_key_probe.cpp - src/coop/dev/spawn_match_probe.cpp - src/coop/dev/native_text_probe.cpp - src/coop/dev/eid_lifetime_trace.cpp - src/coop/dev/join_window_pos_trace.cpp - src/coop/dev/rng_roll_census.cpp - src/coop/dev/death_write_diff.cpp - src/coop/dev/desk_diag.cpp - src/coop/dev/rollover_watch.cpp - src/coop/dev/midnight_drill.cpp - src/coop/dev/world_roll_drill.cpp - src/coop/dev/kerfur_menu_drill.cpp - src/coop/dev/kerfur_convert_drill.cpp - src/coop/dev/kerfus_drill.cpp - src/coop/dev/kerfus_throw_drill.cpp - src/coop/dev/desk_crossing_drill.cpp - src/coop/dev/desk_ping_drill.cpp - src/coop/dev/command_drill.cpp - src/coop/dev/mv_drill.cpp - src/coop/dev/stats_probe.cpp - src/coop/dev/stat_order_drill.cpp - src/coop/dev/chat_drill.cpp - src/coop/dev/l10n_drill.cpp - src/coop/dev/ban_drill.cpp - src/coop/dev/bug_report_drill.cpp - src/coop/dev/grants_drill.cpp - src/coop/dev/pause_quit_drill.cpp - src/coop/dev/game_window.cpp - src/coop/dev/desk_verb_drill.cpp - src/coop/dev/desk_verb_drill_desk.cpp - src/coop/dev/desk_verb_drill_refiner.cpp - src/coop/dev/kerfur_serve_drill.cpp - src/coop/dev/load_reroll_watch.cpp - src/coop/dev/container_selftest.cpp - src/coop/dev/drive_selftest.cpp - src/coop/dev/hand_drop_selftest.cpp - src/coop/dev/run_and_wait_selftest.cpp - src/coop/dev/world_singleton_parity.cpp - src/coop/dev/function_lookup_parity.cpp - src/coop/dev/container_opener_probe.cpp - src/coop/dev/container_view_drill.cpp - src/coop/dev/dev_lanes.cpp - src/coop/dev/physmods_drill.cpp - src/coop/dev/drone_call_drill.cpp - src/coop/dev/sack_drill.cpp - src/coop/dev/fall_drill.cpp - src/coop/dev/atv_spawn_drill.cpp - src/coop/dev/throw_drill.cpp - src/coop/dev/log_drill.cpp - src/coop/dev/server_upgrade_drill.cpp - src/coop/dev/server_drill.cpp - src/coop/dev/sat_console_drill.cpp - src/coop/dev/calib_drill.cpp - src/coop/dev/drive_drill.cpp - src/coop/dev/drive_drill_verbs.cpp - src/coop/dev/download_drill.cpp - src/coop/dev/laptop_drill.cpp - src/coop/dev/grid_drill.cpp - src/coop/dev/grid_drill_checks.cpp - src/coop/dev/grid_drill_upgrade.cpp - src/coop/dev/tower_drill.cpp - src/coop/dev/end_play_probe.cpp - src/coop/dev/death_seam_census.cpp - src/coop/dev/damage_probe.cpp - src/coop/dev/grime_drill.cpp - src/coop/dev/class_lifetime_probe.cpp - src/coop/dev/init_seam_probe.cpp src/coop/dev/floppy_selftest.cpp - src/coop/dev/floppy_selftest_world.cpp - src/coop/dev/hookdrag_selftest.cpp - src/coop/dev/roster_token_selftest.cpp - src/coop/dev/kerfur_census.cpp - src/coop/dev/food_clock_probe.cpp - src/coop/dev/lookat_churn_probe.cpp - src/coop/dev/door_drill.cpp - src/coop/dev/door_drill_aim.cpp - src/coop/dev/event_drill.cpp - src/coop/dev/settings_drill.cpp - src/coop/dev/mannequin_drill.cpp - src/coop/dev/toggle_drill.cpp - src/coop/dev/world_first_check.cpp - src/coop/dev/blackout_drill.cpp - src/coop/dev/lid_drill.cpp - src/coop/dev/subject_drill.cpp - src/coop/dev/light_drill.cpp - src/coop/dev/keypad_drill.cpp - src/coop/dev/lookat_aim_drill.cpp - src/coop/dev/fireext_drill.cpp - src/coop/dev/pry_drill.cpp - src/coop/dev/recycled_slot_drill.cpp - src/coop/dev/rehost_rejoin.cpp - src/coop/dev/puppet_head_probe.cpp - src/coop/dev/wire_census.cpp src/coop/input/input_owner.cpp - src/coop/dev/perf_probe.cpp - src/coop/dev/input_focus_probe.cpp - src/coop/dev/leak_probe.cpp - src/coop/dev/heap_probe.cpp - src/coop/dev/live_store_readout.cpp - src/coop/dev/hotbar_icon_probe.cpp - src/coop/dev/inventory_pickup_drill.cpp - src/coop/dev/drone_probe.cpp - src/coop/dev/native_pile_inert_probe.cpp - src/coop/dev/client_model_probe.cpp - src/coop/dev/atv_eject_drill.cpp - src/coop/dev/atv_probe.cpp - src/coop/dev/atv_tire_probe.cpp - src/coop/dev/pinecone_probe.cpp - src/coop/dev/sleep_probe.cpp - src/coop/dev/event_trigger.cpp - src/coop/dev/event_force.cpp - src/coop/dev/light_group_census.cpp - src/coop/dev/lightswitch_probe.cpp - src/coop/dev/keypad_probe.cpp - src/coop/dev/save_probe.cpp - src/coop/dev/native_ui_probe.cpp - src/coop/dev/worldless_frames.cpp - src/coop/dev/dev_gate.cpp - src/coop/dev/director/player_context.cpp - src/coop/dev/director/control_manager.cpp - src/coop/dev/director/detour.cpp - src/coop/dev/director/proc_walkgrab.cpp - src/coop/dev/director/director_run.cpp - src/coop/dev/director/container_take_probe.cpp - src/coop/dev/director/dup_verifier.cpp - src/coop/dev/director/door_approach.cpp - src/coop/dev/director/routes.cpp - src/coop/dev/director/aim_fan.cpp - src/coop/dev/director/background_walk.cpp - src/coop/dev/director/aimed_grab.cpp - src/coop/dev/force_weather.cpp - src/coop/dev/weather_probe.cpp - src/coop/dev/freecam.cpp - src/coop/dev/flashlight_setup.cpp - src/coop/dev/pos_hud.cpp - src/coop/dev/restore_vitals.cpp - src/coop/dev/vitals_keepalive.cpp - src/coop/dev/set_clock.cpp - src/coop/dev/spawn_menu_unlock.cpp - src/coop/dev/spawn_npc.cpp src/coop/session/teleport_client.cpp - src/coop/dev/menu_proceed.cpp - src/coop/dev/object_overlay.cpp - src/coop/dev/ragdoll_bone_overlay.cpp src/ui/imgui_overlay.cpp src/ui/overlay_diag.cpp src/ui/overlay_test_arm.cpp @@ -804,3 +655,6 @@ set(VOTVCOOP_SOURCES src/l10n/l10n.cpp src/l10n/l10n_selftest.cpp ) + +include(${CMAKE_CURRENT_LIST_DIR}/sources_dev.cmake) +list(APPEND VOTVCOOP_SOURCES ${VOTVCOOP_DEV_SOURCES}) diff --git a/src/votv-coop/sources_dev.cmake b/src/votv-coop/sources_dev.cmake new file mode 100644 index 000000000..b4bba4d99 --- /dev/null +++ b/src/votv-coop/sources_dev.cmake @@ -0,0 +1,158 @@ +# votv-coop/sources_dev.cmake -- the developer drills and probes (src/coop/dev/), one per line. +# +# Split from sources.cmake when that list passed the 800-line cap: these are the instruments a +# run arms by a [dev] row or a drill name, not the mod's own lanes, and they grow with every +# drill. sources.cmake includes this and appends VOTVCOOP_DEV_SOURCES to VOTVCOOP_SOURCES. + +set(VOTVCOOP_DEV_SOURCES + src/coop/dev/force_overdestroy_test.cpp + src/coop/dev/delivery_census_probe.cpp + src/coop/dev/store_table_probe.cpp + src/coop/dev/order_selftest.cpp + src/coop/dev/meadow_selftest.cpp + src/coop/dev/meadow_selftest_rows.cpp + src/coop/dev/order_probe.cpp + src/coop/dev/add_points.cpp + src/coop/dev/spawn_order_probe.cpp + src/coop/dev/prop_birth_key_probe.cpp + src/coop/dev/spawn_match_probe.cpp + src/coop/dev/native_text_probe.cpp + src/coop/dev/eid_lifetime_trace.cpp + src/coop/dev/join_window_pos_trace.cpp + src/coop/dev/rng_roll_census.cpp + src/coop/dev/death_write_diff.cpp + src/coop/dev/desk_diag.cpp + src/coop/dev/rollover_watch.cpp + src/coop/dev/midnight_drill.cpp + src/coop/dev/world_roll_drill.cpp + src/coop/dev/kerfur_menu_drill.cpp + src/coop/dev/kerfur_convert_drill.cpp + src/coop/dev/kerfus_drill.cpp + src/coop/dev/kerfus_throw_drill.cpp + src/coop/dev/desk_crossing_drill.cpp + src/coop/dev/desk_ping_drill.cpp + src/coop/dev/command_drill.cpp + src/coop/dev/mv_drill.cpp + src/coop/dev/stats_probe.cpp + src/coop/dev/stat_order_drill.cpp + src/coop/dev/chat_drill.cpp + src/coop/dev/l10n_drill.cpp + src/coop/dev/ban_drill.cpp + src/coop/dev/bug_report_drill.cpp + src/coop/dev/grants_drill.cpp + src/coop/dev/pause_quit_drill.cpp + src/coop/dev/game_window.cpp + src/coop/dev/desk_verb_drill.cpp + src/coop/dev/desk_verb_drill_desk.cpp + src/coop/dev/desk_verb_drill_refiner.cpp + src/coop/dev/kerfur_serve_drill.cpp + src/coop/dev/load_reroll_watch.cpp + src/coop/dev/container_selftest.cpp + src/coop/dev/drive_selftest.cpp + src/coop/dev/hand_drop_selftest.cpp + src/coop/dev/run_and_wait_selftest.cpp + src/coop/dev/world_singleton_parity.cpp + src/coop/dev/function_lookup_parity.cpp + src/coop/dev/container_opener_probe.cpp + src/coop/dev/container_view_drill.cpp + src/coop/dev/dev_lanes.cpp + src/coop/dev/physmods_drill.cpp + src/coop/dev/drone_call_drill.cpp + src/coop/dev/sack_drill.cpp + src/coop/dev/fall_drill.cpp + src/coop/dev/atv_spawn_drill.cpp + src/coop/dev/throw_drill.cpp + src/coop/dev/log_drill.cpp + src/coop/dev/server_upgrade_drill.cpp + src/coop/dev/server_drill.cpp + src/coop/dev/sat_console_drill.cpp + src/coop/dev/calib_drill.cpp + src/coop/dev/drive_drill.cpp + src/coop/dev/drive_drill_verbs.cpp + src/coop/dev/download_drill.cpp + src/coop/dev/laptop_drill.cpp + src/coop/dev/grid_drill.cpp + src/coop/dev/grid_drill_checks.cpp + src/coop/dev/grid_drill_upgrade.cpp + src/coop/dev/tower_drill.cpp + src/coop/dev/end_play_probe.cpp + src/coop/dev/death_seam_census.cpp + src/coop/dev/damage_probe.cpp + src/coop/dev/grime_drill.cpp + src/coop/dev/class_lifetime_probe.cpp + src/coop/dev/init_seam_probe.cpp + src/coop/dev/floppy_selftest_world.cpp + src/coop/dev/hookdrag_selftest.cpp + src/coop/dev/roster_token_selftest.cpp + src/coop/dev/kerfur_census.cpp + src/coop/dev/food_clock_probe.cpp + src/coop/dev/lookat_churn_probe.cpp + src/coop/dev/door_drill.cpp + src/coop/dev/door_drill_aim.cpp + src/coop/dev/event_drill.cpp + src/coop/dev/settings_drill.cpp + src/coop/dev/mannequin_drill.cpp + src/coop/dev/toggle_drill.cpp + src/coop/dev/world_first_check.cpp + src/coop/dev/blackout_drill.cpp + src/coop/dev/lid_drill.cpp + src/coop/dev/subject_drill.cpp + src/coop/dev/light_drill.cpp + src/coop/dev/keypad_drill.cpp + src/coop/dev/lookat_aim_drill.cpp + src/coop/dev/fireext_drill.cpp + src/coop/dev/pry_drill.cpp + src/coop/dev/recycled_slot_drill.cpp + src/coop/dev/rehost_rejoin.cpp + src/coop/dev/puppet_head_probe.cpp + src/coop/dev/wire_census.cpp + src/coop/dev/perf_probe.cpp + src/coop/dev/input_focus_probe.cpp + src/coop/dev/leak_probe.cpp + src/coop/dev/heap_probe.cpp + src/coop/dev/live_store_readout.cpp + src/coop/dev/hotbar_icon_probe.cpp + src/coop/dev/inventory_pickup_drill.cpp + src/coop/dev/drone_probe.cpp + src/coop/dev/native_pile_inert_probe.cpp + src/coop/dev/client_model_probe.cpp + src/coop/dev/atv_eject_drill.cpp + src/coop/dev/atv_probe.cpp + src/coop/dev/atv_tire_probe.cpp + src/coop/dev/pinecone_probe.cpp + src/coop/dev/sleep_probe.cpp + src/coop/dev/event_trigger.cpp + src/coop/dev/event_force.cpp + src/coop/dev/light_group_census.cpp + src/coop/dev/lightswitch_probe.cpp + src/coop/dev/keypad_probe.cpp + src/coop/dev/save_probe.cpp + src/coop/dev/native_ui_probe.cpp + src/coop/dev/worldless_frames.cpp + src/coop/dev/dev_gate.cpp + src/coop/dev/director/player_context.cpp + src/coop/dev/director/control_manager.cpp + src/coop/dev/director/detour.cpp + src/coop/dev/director/proc_walkgrab.cpp + src/coop/dev/director/director_run.cpp + src/coop/dev/director/container_take_probe.cpp + src/coop/dev/director/dup_verifier.cpp + src/coop/dev/director/door_approach.cpp + src/coop/dev/director/routes.cpp + src/coop/dev/director/aim_fan.cpp + src/coop/dev/director/background_walk.cpp + src/coop/dev/director/aimed_grab.cpp + src/coop/dev/force_weather.cpp + src/coop/dev/weather_probe.cpp + src/coop/dev/freecam.cpp + src/coop/dev/flashlight_setup.cpp + src/coop/dev/pos_hud.cpp + src/coop/dev/restore_vitals.cpp + src/coop/dev/vitals_keepalive.cpp + src/coop/dev/set_clock.cpp + src/coop/dev/spawn_menu_unlock.cpp + src/coop/dev/spawn_npc.cpp + src/coop/dev/menu_proceed.cpp + src/coop/dev/object_overlay.cpp + src/coop/dev/ragdoll_bone_overlay.cpp +) From b8de6b839bd24d8cc000729a9939a667cacbd979 Mon Sep 17 00:00:00 2001 From: pelmentor Date: Sat, 10 Oct 2026 15:13:18 +0600 Subject: [PATCH 104/306] [build] the template gate's ImGui header is fetched in CI, or refused repo-gates went red on 5af39a52b: the translation template gate reads the pinned imgui.h for the calls that take a label, and the workflow's checkout carries no submodules, so it died on a missing file. The workflow now fetches that one submodule, shallow, as the gates' last input; the gate refuses with the command to run when the header is absent. Measured in a fresh clone: refused, then fetched and PASS. Co-Authored-By: Claude Opus 5.5 --- .github/ci/l10n_pot.py | 5 +++++ .github/workflows/repo-gates.yml | 6 ++++++ 2 files changed, 11 insertions(+) diff --git a/.github/ci/l10n_pot.py b/.github/ci/l10n_pot.py index 86d80986d..0112e0282 100644 --- a/.github/ci/l10n_pot.py +++ b/.github/ci/l10n_pot.py @@ -267,6 +267,11 @@ def signature(fmt): # ---- the scan ------------------------------------------------------------------------------------------------- def widget_names(): + # The pinned header is the authority on which calls take a label; without it the gate cannot judge a call site, + # so it refuses rather than read none. + if not IMGUI_H.is_file(): + sys.exit("l10n_pot: %s is absent -- run: git submodule update --init src/votv-coop/third_party/imgui" + % IMGUI_H.relative_to(ROOT).as_posix()) text = IMGUI_H.read_text(encoding="utf-8", errors="replace") return set(re.findall(r"IMGUI_API\s+[\w\s\*]*?\b(\w+)\s*\(\s*const\s+char\s*\*\s*(?:label|name|str_id)\b", text)) diff --git a/.github/workflows/repo-gates.yml b/.github/workflows/repo-gates.yml index 4989ac0aa..7dfbf5de8 100644 --- a/.github/workflows/repo-gates.yml +++ b/.github/workflows/repo-gates.yml @@ -148,5 +148,11 @@ jobs: - name: Plural vectors gate (the l10n selftest's expected forms are the independent evaluator's) run: python3 .github/ci/l10n_vectors.py --check + - name: ImGui submodule (the template gate reads the pinned imgui.h for the calls that take a label) + # Last of the gates' inputs, so no earlier gate reads a submodule it does not read elsewhere. Shallow, + # with a full fetch when a direct fetch of the pinned commit is refused. + run: | + git submodule update --init --depth 1 src/votv-coop/third_party/imgui || git submodule update --init src/votv-coop/third_party/imgui + - name: Translation template gate (the template is current; only allowlisted files extract; call sites follow the rules) run: python3 .github/ci/l10n_pot.py --check From 7cc03b7f541ff837aa34175c122d6667f73e86ba Mon Sep 17 00:00:00 2001 From: pelmentor Date: Sat, 10 Oct 2026 15:45:21 +0600 Subject: [PATCH 105/306] [build] a workflow script reads its values through env, never pasted GitHub pastes a ${{ }} expression into a run: script as text, so a value holding quotes or $( ) runs as code in whatever step holds it. Every value a release or build script reads now comes through env:, and the release tag is held to the tag grammar before any command sees it. workflow_expr_gate.py refuses an expression inside a run: value in every workflow, with a must-fire fixture on each run. Evidence: the gate finds 21 sites at main and 0 here; under pwsh, main's Resolve tag step, given a tag carrying code, ran it; this one refused it. Co-Authored-By: Claude Opus 5.5 --- .github/ci/workflow_expr_gate.py | 111 +++++++++++++++++++++++++++++ .github/workflows/build-core.yml | 32 +++++---- .github/workflows/release-core.yml | 37 ++++++---- .github/workflows/repo-gates.yml | 3 + CONTRIBUTING.md | 3 +- 5 files changed, 161 insertions(+), 25 deletions(-) create mode 100644 .github/ci/workflow_expr_gate.py diff --git a/.github/ci/workflow_expr_gate.py b/.github/ci/workflow_expr_gate.py new file mode 100644 index 000000000..7e66fb367 --- /dev/null +++ b/.github/ci/workflow_expr_gate.py @@ -0,0 +1,111 @@ +#!/usr/bin/env python3 +"""workflow_expr_gate -- no ${{ }} expression is pasted into a run: script. + +GitHub substitutes an expression into a run: script as TEXT before the shell parses it, so +a value that carries quotes or $( ) becomes code: a tag name, a dispatch input or a job +output turns into a command in whatever step holds it. A value reaches a script through +the step's env: and is read as a variable ($env:NAME, "$NAME"), where it stays data. + +FAILS on any ${{ inside a run: value, block or inline, in .github/workflows/*.yml and in +composite actions. A comment line inside the script counts: GitHub substitutes there too. +Every run first fires two fixtures (a violating workflow must fail, a clean one must pass); +if either does not hold, the gate cannot be trusted and exits 2. + +USAGE + python .github/ci/workflow_expr_gate.py +""" +import pathlib +import re +import sys + +ROOT = pathlib.Path(__file__).resolve().parents[2] +RUN_KEY = re.compile(r"^(?P\s*(?:-\s+)?)run:(?P.*)$") +BLOCK = re.compile(r"^\s*[|>][-+0-9]*\s*(#.*)?$") + + +def violations(text): + """(line number, line) of every ${{ inside a run: value of one YAML file.""" + found = [] + block_indent = None + for no, line in enumerate(text.splitlines(), 1): + if block_indent is not None: + body = line.strip() + if body and len(line) - len(line.lstrip()) <= block_indent: + block_indent = None + else: + if "${{" in line: + found.append((no, line.strip())) + continue + m = RUN_KEY.match(line) + if not m: + continue + if BLOCK.match(m.group("rest")): + block_indent = len(m.group("lead")) + elif "${{" in m.group("rest"): + found.append((no, line.strip())) + return found + + +FIXTURE_BAD = """jobs: + a: + steps: + - name: inline + run: echo "${{ inputs.x }}" + - shell: pwsh + run: | + $t = '${{ github.event.inputs.tag }}' + # ${{ secrets.K }} in a comment is substituted too + env: + OK: ${{ inputs.y }} +""" +FIXTURE_GOOD = """jobs: + a: + runs-on: ${{ matrix.os }} + steps: + - name: env passes the value + if: ${{ inputs.go }} + env: + TAG: ${{ inputs.tag }} + run: | + Write-Host "tag $env:TAG" + - uses: actions/cache@0 + with: + key: k-${{ hashFiles('x') }} +""" + + +def fixtures_hold(): + bad = [no for no, _ in violations(FIXTURE_BAD)] + good = violations(FIXTURE_GOOD) + if bad != [5, 8, 9]: + print(f"workflow_expr_gate: FIXTURE the violating workflow fired on lines {bad}, expected [5, 8, 9]") + return False + if good: + print(f"workflow_expr_gate: FIXTURE the clean workflow fired: {good}") + return False + return True + + +def main(): + if not fixtures_hold(): + return 2 + files = sorted((ROOT / ".github" / "workflows").glob("*.y*ml")) + files += sorted((ROOT / ".github" / "actions").glob("**/action.y*ml")) + if not files: + print("workflow_expr_gate: FAIL no workflow found under .github/workflows") + return 2 + bad = 0 + for path in files: + for no, line in violations(path.read_text(encoding="utf-8")): + rel = path.relative_to(ROOT).as_posix() + print(f"{rel}:{no}: an expression inside a run: script -- pass it through env: ({line})") + bad += 1 + if bad: + print(f"workflow_expr_gate: FAIL {bad} expression(s) inside run: scripts") + return 1 + print(f"workflow_expr_gate: PASS {len(files)} workflow files, no expression inside a run: script") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/workflows/build-core.yml b/.github/workflows/build-core.yml index d8043e379..d5af5b233 100644 --- a/.github/workflows/build-core.yml +++ b/.github/workflows/build-core.yml @@ -7,6 +7,8 @@ # contrib branch shadow this file while the caller's diff reads clean (R22). # Changes here land only as human commits on main; this file's sha256 is part # of the release fingerprint (.github/ci/fingerprint.json, design D3/R23). +# No ${{ }} expression is pasted into a run: script (workflow_expr_gate.py): +# a script reads its values through env:. # Build steps based on PR #2 by huoyan1231 (reworked per design D2). name: build-core @@ -58,12 +60,14 @@ jobs: - name: Self-check -- tree is the requested source (R27) shell: pwsh + env: + SOURCE_SHA: ${{ inputs.source-sha }} run: | $head = (git rev-parse HEAD).Trim() Write-Host "HEAD = $head" - Write-Host "source-sha = ${{ inputs.source-sha }}" - Write-Host "github.sha = ${{ github.sha }} (evidence only -- on a recovery dispatch this is main's HEAD while the tree is the tag's)" - if ($head -ne '${{ inputs.source-sha }}') { throw "checkout mismatch: HEAD $head != source-sha ${{ inputs.source-sha }}" } + Write-Host "source-sha = $env:SOURCE_SHA" + Write-Host "github.sha = $env:GITHUB_SHA (evidence only -- on a recovery dispatch this is main's HEAD while the tree is the tag's)" + if ($head -ne $env:SOURCE_SHA) { throw "checkout mismatch: HEAD $head != source-sha $env:SOURCE_SHA" } - name: Init build submodules (third_party only; reference/* never fetched) shell: pwsh @@ -232,8 +236,10 @@ jobs: - name: Install vcpkg (clone full + checkout the baseline pin + bootstrap) shell: pwsh + env: + PIN: ${{ steps.baseline.outputs.sha }} run: | - $pin = '${{ steps.baseline.outputs.sha }}' + $pin = $env:PIN if (-not (Test-Path "$env:VCPKG_ROOT\.git")) { git clone https://github.com/microsoft/vcpkg.git $env:VCPKG_ROOT } @@ -271,16 +277,18 @@ jobs: - name: Configure (CMake + vcpkg manifest mode) shell: pwsh + env: + GENERATOR: ${{ steps.vs.outputs.generator }} run: | - cmake -S src/votv-coop -B ${{ env.BUILD_DIR }} ` - -G "${{ steps.vs.outputs.generator }}" -A x64 ` - -DCMAKE_TOOLCHAIN_FILE="${{ env.VCPKG_ROOT }}/scripts/buildsystems/vcpkg.cmake" ` - -DVCPKG_TARGET_TRIPLET=${{ env.VCPKG_TRIPLET }} ` + cmake -S src/votv-coop -B $env:BUILD_DIR ` + -G $env:GENERATOR -A x64 ` + -DCMAKE_TOOLCHAIN_FILE="$env:VCPKG_ROOT/scripts/buildsystems/vcpkg.cmake" ` + -DVCPKG_TARGET_TRIPLET="$env:VCPKG_TRIPLET" ` -DVCPKG_MANIFEST_MODE=ON - name: Build (Release) shell: pwsh - run: cmake --build ${{ env.BUILD_DIR }} --config Release + run: cmake --build $env:BUILD_DIR --config Release - name: ABI gate (D-3 -- the built DLL imports NOTHING from UE4SS) # The C-ABI slim contract's whole payoff is a number: 0 imported UE4SS @@ -296,7 +304,7 @@ jobs: # silently discarded the drill's failure -- the script runs the drill # first and returns 2 immediately if it cannot fire. shell: pwsh - run: python .github/ci/abi_gate.py --drill "${{ env.BUILD_DIR }}/Release/main.dll" + run: python .github/ci/abi_gate.py --drill "$env:BUILD_DIR/Release/main.dll" - name: Package the r2modman zip + verify it (CI evidence, pak-less by construction) # THE DISTRIBUTABLE IS ASSEMBLED ON EVERY BUILD RUN, not first at release time. @@ -312,7 +320,7 @@ jobs: # stocked, so this step proves the STRUCTURE, never the payload set. shell: pwsh run: | - pwsh -File .github/ci/package.ps1 -BuildDir "${{ env.BUILD_DIR }}/Release" ` + pwsh -File .github/ci/package.ps1 -BuildDir "$env:BUILD_DIR/Release" ` -OutDir "$env:RUNNER_TEMP/package" exit $LASTEXITCODE @@ -322,7 +330,7 @@ jobs: run: | $out = Join-Path $env:RUNNER_TEMP 'artifact' New-Item -ItemType Directory $out | Out-Null - $dlls = @(Get-ChildItem "${{ env.BUILD_DIR }}/Release/*.dll") + $dlls = @(Get-ChildItem "$env:BUILD_DIR/Release/*.dll") if ($dlls.Count -eq 0) { throw 'no DLLs produced' } foreach ($d in $dlls) { Copy-Item $d.FullName $out diff --git a/.github/workflows/release-core.yml b/.github/workflows/release-core.yml index 0a19dac78..92ab063db 100644 --- a/.github/workflows/release-core.yml +++ b/.github/workflows/release-core.yml @@ -5,6 +5,9 @@ # checkout (checkout#2) -- the self-check below guards the JUDGE, not just the # evidence. The run logs workflow_ref/workflow_sha as evidence (not a gate -- # a gate cannot guard itself; residual D7(a)). +# No ${{ }} expression is pasted into a run: script (workflow_expr_gate.py): +# every value a script reads comes through env:, and the tag name is held to +# the release grammar before any command sees it. name: release-core on: @@ -56,19 +59,25 @@ jobs: $main = (git rev-parse origin/main).Trim() Write-Host "judge HEAD = $head" Write-Host "origin/main = $main" - Write-Host "workflow_ref = ${{ github.workflow_ref }} (evidence)" - Write-Host "workflow_sha = ${{ github.workflow_sha }} (evidence)" + Write-Host "workflow_ref = $env:GITHUB_WORKFLOW_REF (evidence)" + Write-Host "workflow_sha = $env:GITHUB_WORKFLOW_SHA (evidence)" if ($head -ne $main) { throw "judge checkout is not origin/main HEAD (main moved mid-run?) -- re-run" } - - name: Resolve tag + - name: Resolve tag (held to the release grammar before any command reads it) id: resolve shell: pwsh + env: + DISPATCH_TAG: ${{ inputs.tag }} + CALLER_TAG: ${{ inputs.tag-name }} + CALLER_SHA: ${{ inputs.tag-sha }} run: | - $tag = if ('${{ github.event_name }}' -eq 'workflow_dispatch') { '${{ inputs.tag }}' } else { '${{ inputs.tag-name }}' } + . ./.github/ci/ledger_lib.ps1 + $tag = if ($env:GITHUB_EVENT_NAME -eq 'workflow_dispatch') { $env:DISPATCH_TAG } else { $env:CALLER_TAG } if (-not $tag) { throw 'no tag input' } + if (-not (ConvertFrom-ReleaseTag $tag)) { throw 'the tag input fails the release tag grammar' } $sha = (git rev-list -n1 $tag).Trim() if (-not $sha) { throw "tag '$tag' not found on origin" } - Write-Host "tag $tag -> $sha (caller evidence: '${{ inputs.tag-sha }}')" + Write-Host "tag $tag -> $sha (caller evidence: '$env:CALLER_SHA')" "tag-name=$tag" | Out-File -Append $env:GITHUB_OUTPUT "tag-sha=$sha" | Out-File -Append $env:GITHUB_OUTPUT @@ -87,8 +96,10 @@ jobs: shell: pwsh env: GH_TOKEN: ${{ github.token }} + TAG_NAME: ${{ steps.resolve.outputs.tag-name }} + TAG_SHA: ${{ steps.resolve.outputs.tag-sha }} run: | - pwsh -File .github/ci/judge.ps1 -TagName '${{ steps.resolve.outputs.tag-name }}' -TagShaEvidence '${{ steps.resolve.outputs.tag-sha }}' + pwsh -File .github/ci/judge.ps1 -TagName $env:TAG_NAME -TagShaEvidence $env:TAG_SHA $code = $LASTEXITCODE # Explicit exits: without them the step ends with the child's # $LASTEXITCODE (10) and the no-op verdict turned the job RED -- @@ -178,18 +189,20 @@ jobs: env: GH_TOKEN: ${{ github.token }} MULTIVOID_RELEASE_KEY: ${{ secrets.MULTIVOID_RELEASE_KEY }} + TAG_NAME: ${{ needs.judge.outputs.tag-name }} + TAG_SHA: ${{ needs.judge.outputs.tag-sha }} run: | - pwsh -File .github/ci/publish.ps1 ` - -TagName '${{ needs.judge.outputs.tag-name }}' ` - -TagSha '${{ needs.judge.outputs.tag-sha }}' ` - -ArtifactDir _release_artifact + pwsh -File .github/ci/publish.ps1 -TagName $env:TAG_NAME -TagSha $env:TAG_SHA -ArtifactDir _release_artifact Get-Content _release_artifact/fingerprint-dump.json | Out-Host - name: Summary shell: pwsh + env: + TAG_NAME: ${{ needs.judge.outputs.tag-name }} + TAG_SHA: ${{ needs.judge.outputs.tag-sha }} run: | - "## Release ${{ needs.judge.outputs.tag-name }}" | Out-File -Append $env:GITHUB_STEP_SUMMARY - "source: ${{ needs.judge.outputs.tag-sha }}" | Out-File -Append $env:GITHUB_STEP_SUMMARY + "## Release $env:TAG_NAME" | Out-File -Append $env:GITHUB_STEP_SUMMARY + "source: $env:TAG_SHA" | Out-File -Append $env:GITHUB_STEP_SUMMARY "Ritual: verify the release page, then append the 'published' ledger row (see publish log; STABLE also: master env + verify_latest.ps1)." | Out-File -Append $env:GITHUB_STEP_SUMMARY already-published: diff --git a/.github/workflows/repo-gates.yml b/.github/workflows/repo-gates.yml index 7dfbf5de8..ba083dbcc 100644 --- a/.github/workflows/repo-gates.yml +++ b/.github/workflows/repo-gates.yml @@ -142,6 +142,9 @@ jobs: - name: Master-contact gate (nothing tells an outside server where a player is, unasked) run: python3 .github/ci/master_contact_gate.py + - name: Workflow expression gate (a run script reads its values through env, never a pasted expression) + run: python3 .github/ci/workflow_expr_gate.py + - name: ReliableKind routing gate (every wire kind reaches a receiver, or names why not) run: python3 .github/ci/reliablekind_gate.py diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 19759893c..cebb98354 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -99,7 +99,8 @@ A change is not done when it compiles. Before a pull request: Every push and pull request runs the source gates (`.github/workflows/repo-gates.yml`): the config-registry gate, the peer-slot generation gate, the atlas gate, the master-contact and -reliable-kind gates, the MinHook free-call and GC-pin rules, the public-leak gate, and the +reliable-kind gates, the MinHook free-call and GC-pin rules, the workflow-expression gate (a +workflow script reads its values through `env:`), the public-leak gate, and the commit-message and public-prose checks described below. The Windows build is run by hand (Actions -> **build** -> Run workflow); it is what runs the zero-import ABI gate on the built DLL and the package drill, since those need a compiled artifact. From f97b1687844f11f803a70ceee7919936f4edb969 Mon Sep 17 00:00:00 2001 From: pelmentor Date: Sat, 10 Oct 2026 15:45:39 +0600 Subject: [PATCH 106/306] [build] release jobs run with a read-only token; publish alone writes The release workflow granted contents: write to every job, so the judge and the cacheless build, which runs the tagged tree and third-party builds, held a token that can edit releases. The workflow default is now contents: read and only the publish job, the one that writes the Release API, asks for write. The release checkouts and the build checkout no longer leave the token in .git/config (persist-credentials: false); the repository is public, so their fetches need none. Evidence: the judge reads releases through gh (a read) and fetches main and tags anonymously; no step but publish calls a writing endpoint. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/build-core.yml | 1 + .github/workflows/release-core.yml | 8 +++++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build-core.yml b/.github/workflows/build-core.yml index d5af5b233..3bbf441a3 100644 --- a/.github/workflows/build-core.yml +++ b/.github/workflows/build-core.yml @@ -55,6 +55,7 @@ jobs: with: ref: ${{ inputs.ref }} submodules: false + persist-credentials: false # Full clone: submodule-pinned commits + vcpkg versions DB need history. fetch-depth: 0 diff --git a/.github/workflows/release-core.yml b/.github/workflows/release-core.yml index 92ab063db..6f9272a8f 100644 --- a/.github/workflows/release-core.yml +++ b/.github/workflows/release-core.yml @@ -32,8 +32,10 @@ concurrency: group: release # full serialization; a queued twin lands on ALREADY_PUBLISHED cancel-in-progress: false +# Read-only for every job but publish, the one that writes the Release API (D7). +# The v* tag ruleset lets only an admin create, move or delete a release tag. permissions: - contents: write # Release API only (D7); the v* tag ruleset blocks robot tags independently + contents: read jobs: judge: @@ -49,6 +51,7 @@ jobs: with: ref: main fetch-depth: 0 + persist-credentials: false - name: Self-check -- judge tree is origin/main HEAD shell: pwsh @@ -131,6 +134,8 @@ jobs: needs: [judge, build] if: needs.judge.outputs.verdict == 'publish' runs-on: windows-latest + permissions: + contents: write # The release key is this environment's secret. The job waits here for the reviewer's # approval, and only after it can read the secret; the first step fails the job when the # environment holds none, which is after the build and the approval, not at once. @@ -159,6 +164,7 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: ref: ${{ needs.judge.outputs.tag-sha }} + persist-credentials: false # Only the *.ps1 predicates and the release signer are overlaid, and only from main. A tag can be # created by anyone who can push one; the refuse-to-publish logic must not From 76e9cad7a710ec273f4d48fd4db9885909bfe479 Mon Sep 17 00:00:00 2001 From: pelmentor Date: Sat, 10 Oct 2026 15:45:47 +0600 Subject: [PATCH 107/306] [build] the build lane drops its dead opus clone and its pak-less label The build lane cloned opus v1.5.2 by a movable tag when the submodule was empty after init; every tree since the opus .gitmodules entry populates it, so the step never runs, and when it ran it fetched unpinned code. It goes; BUILDING.md keeps the manual fix for an old local clone. The package step's name and comment still said a CI zip is pak-less, which stopped being true when assets/paks became tracked: a CI zip carries the same starter paks a release zip does. Evidence: main's tree carries the opus gitlink ddbe483 (v1.5.2) and the .gitmodules entry; git ls-files lists assets/paks/scientists.pak. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/build-core.yml | 21 +++------------------ 1 file changed, 3 insertions(+), 18 deletions(-) diff --git a/.github/workflows/build-core.yml b/.github/workflows/build-core.yml index 3bbf441a3..bb47546eb 100644 --- a/.github/workflows/build-core.yml +++ b/.github/workflows/build-core.yml @@ -74,18 +74,6 @@ jobs: shell: pwsh run: git submodule update --init --recursive src/votv-coop/third_party - - name: Ensure opus submodule present (BUILDING.md troubleshooting fallback) - # Old clones lack the opus .gitmodules entry; --recursive then skips - # the gitlink silently and configure fails. - shell: pwsh - run: | - $opus = 'src/votv-coop/third_party/opus' - if (-not (Test-Path (Join-Path $opus 'CMakeLists.txt'))) { - Write-Host 'opus submodule empty -> cloning v1.5.2 directly' - Remove-Item -Recurse -Force $opus -ErrorAction SilentlyContinue - git clone --depth 1 -b v1.5.2 https://github.com/xiph/opus.git $opus - } else { Write-Host 'opus present, ok' } - - name: Config registry gate (arc 3 C5 -- dead rows / write-only / alias / ratchet surface) # Standing gate with fixture-injected must-fire controls each run; the # script exits explicitly (pwsh last-child-code lesson). Cheap text @@ -307,18 +295,15 @@ jobs: shell: pwsh run: python .github/ci/abi_gate.py --drill "$env:BUILD_DIR/Release/main.dll" - - name: Package the r2modman zip + verify it (CI evidence, pak-less by construction) + - name: Package the r2modman zip + verify it (CI evidence) # THE DISTRIBUTABLE IS ASSEMBLED ON EVERY BUILD RUN, not first at release time. # `package.ps1` is the same script the release lane runs, and it verifies the # zip it just wrote with the predicate the drill above proved can fail -- so a # change that quietly breaks the package tree (a moved payload, a manifest key, # an identity parse) fails HERE, on the commit that caused it. # - # LAWFULLY PAK-LESS. `assets/paks/` holds untracked binaries and does not exist - # in a CI checkout; package.ps1 documents that a CI zip is pak-less and that a - # pak-less package is still a fully working mod (client_model falls back to the - # game's own skin). A RELEASE zip is assembled on a box where that dir is - # stocked, so this step proves the STRUCTURE, never the payload set. + # The starter paks are tracked in `assets/paks/`, so this zip carries the same + # paks a release zip does; package.ps1 stages them by default. shell: pwsh run: | pwsh -File .github/ci/package.ps1 -BuildDir "$env:BUILD_DIR/Release" ` From 852f6ad12ba8abfc64ac2cec8478d903bbf91dc8 Mon Sep 17 00:00:00 2001 From: pelmentor Date: Sat, 10 Oct 2026 15:45:57 +0600 Subject: [PATCH 108/306] [build] publish re-reads the tag before the draft and before the flip A release hangs on the tag name while its assets come from the judged commit, and publish never looked at the tag again after the judge. It now reads the tag from origin with git ls-remote, peeled, right before the draft and right before the flip that makes the release public, and refuses when the tag names another commit. TagSha is also held to a full lowercase sha before use. Evidence: against origin, v0.9.0n-b208-dev passes with ec2841e7 and is refused with ba6d8c39; a stubbed annotated tag is judged by its peel. Co-Authored-By: Claude Opus 5.5 --- .github/ci/publish.ps1 | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/.github/ci/publish.ps1 b/.github/ci/publish.ps1 index ffe508754..70e7f552b 100644 --- a/.github/ci/publish.ps1 +++ b/.github/ci/publish.ps1 @@ -22,6 +22,24 @@ $ErrorActionPreference = 'Stop' $tag = ConvertFrom-ReleaseTag $TagName if (-not $tag) { throw "tag '$TagName' fails the grammar (the judge should have refused)" } +if ($TagSha -cnotmatch '^[0-9a-f]{40}$') { throw "TagSha '$TagSha' is not a full lowercase commit sha" } + +# The release hangs on the tag NAME, its assets on TagSha. The tag is re-read from +# origin before the draft and again right before the flip makes the release public: +# a tag moved after the judge would publish these assets under another commit. +function Assert-TagStillNamesJudgedSha { + $lines = @(git ls-remote --tags origin "refs/tags/$TagName*") + if ($LASTEXITCODE -ne 0) { throw "re-reading tag '$TagName' from origin failed" } + $direct = $null; $peeled = $null + foreach ($line in $lines) { + $sha, $ref = "$line" -split "`t", 2 + if ($ref -ceq "refs/tags/$TagName") { $direct = $sha } + elseif ($ref -ceq "refs/tags/$TagName^{}") { $peeled = $sha } + } + $now = if ($peeled) { $peeled } else { $direct } + if ($now -cne $TagSha) { throw "tag '$TagName' now names '$now', not the judged $TagSha -- refusing to publish" } + Write-Host "tag $TagName still names the judged $TagSha" +} # --- Assets: ONE zip, assembled from the artifact payload (UE4SS_ARC 7.4c/8.3) -- # The artifact carries the tagged cacheless rebuild's main.dll; the zip is @@ -116,6 +134,7 @@ if ($shaMatches.Count -ne $assets.Count) { throw "final body carries $($shaMatch $bodyFile = Join-Path ([System.IO.Path]::GetTempPath()) "multivoid-relbody-$($tag.N).md" Set-Content -LiteralPath $bodyFile -Value $body -Encoding utf8 -NoNewline +Assert-TagStillNamesJudgedSha gh release create $TagName --repo $Repo --draft --title $title --notes-file $bodyFile @($assets.FullName) | Out-Host if ($LASTEXITCODE -ne 0) { throw 'draft creation failed' } @@ -132,6 +151,7 @@ foreach ($a in $assets) { } # --- Flip ----------------------------------------------------------------- +Assert-TagStillNamesJudgedSha $flipArgs = @('release', 'edit', $TagName, '--repo', $Repo, '--draft=false') $flipArgs += $(if ($tag.Dev) { '--prerelease' } else { '--prerelease=false' }) & gh @flipArgs | Out-Host From a5c2b5372a64e26d7ecb48127dfec5bb02189912 Mon Sep 17 00:00:00 2001 From: pelmentor Date: Sat, 10 Oct 2026 15:45:57 +0600 Subject: [PATCH 109/306] [build] keygen refuses unless only an admin can create or move a v* tag The release environment admits v* tags, so whoever can create or move one reaches the key behind the reviewer. keygen now also reads the repository's rulesets and refuses unless an active tag ruleset covers refs/tags/v* with no exclusion, blocks creation, update and deletion, and lets only the admin role bypass it. The release workflow's header no longer claims a recovery dispatch always runs main's YAML: it runs the ref it is dispatched on, which the environment limits to main and v*. Evidence: keygen drill 66 checks pass, 22 fail against main's signer; the check accepts the live repository's v-tags-admin-only ruleset. Co-Authored-By: Claude Opus 5.5 --- .github/ci/release_sign.py | 50 +++++++++++++++- .github/ci/release_sign_keygen_drill.py | 76 +++++++++++++++++++++---- .github/workflows/release-core.yml | 7 ++- docs/release.md | 5 +- 4 files changed, 121 insertions(+), 17 deletions(-) diff --git a/.github/ci/release_sign.py b/.github/ci/release_sign.py index e65f261ce..c5b98362a 100644 --- a/.github/ci/release_sign.py +++ b/.github/ci/release_sign.py @@ -426,6 +426,50 @@ def _check_protection(environment, policies): raise _unprotected("branch policies") +# The release environment admits the v* tags, so whoever may create or move one reaches the key. +# Only an admin may: an active tag ruleset over refs/tags/v* with no exclusion, refusing creation, +# update and deletion to all but the admin repository role (actor 5, as the live ruleset reads). +_TAG_PATTERN = "refs/tags/v*" +_TAG_RULES = {"creation", "update", "deletion"} +_ADMIN_ROLE = ("RepositoryRole", 5) + + +def _unruled(check): + return SignError("protection", 3, "release_sign: the release tags are not held by a ruleset as " + "docs/release.md sets it (%s)" % check) + + +def _tag_ruleset_flaw(ruleset): + """Why one ruleset does not hold the release tags, or None when it does.""" + conditions = ruleset.get("conditions") if isinstance(ruleset, dict) else None + names = conditions.get("ref_name") if isinstance(conditions, dict) else None + if not (isinstance(names, dict) and isinstance(names.get("include"), list) + and _TAG_PATTERN in names["include"] and names.get("exclude") == []): + return "the v* pattern" + rules = ruleset.get("rules") + kinds = {r.get("type") for r in rules if isinstance(r, dict)} if isinstance(rules, list) else set() + if not _TAG_RULES <= kinds: + return "creation, update and deletion" + bypass = ruleset.get("bypass_actors") + if not isinstance(bypass, list) or any( + not isinstance(a, dict) or (a.get("actor_type"), a.get("actor_id")) != _ADMIN_ROLE + for a in bypass): + return "admin-only bypass" + return None + + +def _check_tag_ruleset(runner, repo): + listed = _gh_api(runner, "repos/%s/rulesets" % repo) + ids = [r.get("id") for r in listed if isinstance(r, dict) and r.get("target") == "tag" + and r.get("enforcement") == "active"] if isinstance(listed, list) else [] + ids = [i for i in ids if isinstance(i, int) and not isinstance(i, bool)] + if not ids: + raise _unruled("no active tag ruleset") + flaws = [_tag_ruleset_flaw(_gh_api(runner, "repos/%s/rulesets/%d" % (repo, i))) for i in ids] + if None not in flaws: + raise _unruled(flaws[0]) + + def _key_id(text): """The strict parse `sign --build` applies to its number: plain ASCII digits, no sign, no underscore, no leading zero.""" @@ -435,8 +479,9 @@ def _key_id(text): def cmd_keygen(args, runner, out): - """The order is the custody: refuse an unprotected environment, then make the key, check its - row, hand the seed to gh's standard input, and print the row. No file is written.""" + """The order is the custody: refuse an unprotected environment or unheld release tags, then + make the key, check its row, hand the seed to gh's standard input, and print the row. No file + is written.""" if _REPO.fullmatch(args.repo) is None: raise SignError("usage", 2, "release_sign: --repo must be owner/name") rows = _table_rows() @@ -449,6 +494,7 @@ def cmd_keygen(args, runner, out): environment = _gh_api(runner, base) policies = _gh_api(runner, base + "/deployment-branch-policies") _check_protection(environment, policies) + _check_tag_ruleset(runner, args.repo) seed = os.urandom(32) pub = ed25519_public(seed) diff --git a/.github/ci/release_sign_keygen_drill.py b/.github/ci/release_sign_keygen_drill.py index 897bd917f..4aed64a64 100644 --- a/.github/ci/release_sign_keygen_drill.py +++ b/.github/ci/release_sign_keygen_drill.py @@ -3,8 +3,8 @@ Standard library only. A fake runner answers canned `gh` output and records every call's argv and keyword arguments, so no process starts and the real `gh` is never run; each run happens inside a -temporary working directory with stdout and stderr captured. The fake answers only the three -commands keygen may run, with the output a real `gh` prints (so an echo of it is seen), and acts +temporary working directory with stdout and stderr captured. The fake answers only the +commands keygen may run (four reads, then the secret), with the output a real `gh` prints (so an echo of it is seen), and acts on the keyword arguments the way `subprocess.run` does. Three mutants are always run and the same assertion helper as the green arms must flag each of them; one starts a process, which the guard below must refuse. @@ -61,6 +61,10 @@ REPO = "drill-owner/drill-repo" ENV_ARGV = ["gh", "api", "repos/%s/environments/release" % REPO] POLICIES_ARGV = ["gh", "api", "repos/%s/environments/release/deployment-branch-policies" % REPO] +RULESETS_ARGV = ["gh", "api", "repos/%s/rulesets" % REPO] +TAG_RULESET_ID = 11 +RULESET_ARGV = ["gh", "api", "repos/%s/rulesets/%d" % (REPO, TAG_RULESET_ID)] +READS = [ENV_ARGV, POLICIES_ARGV, RULESETS_ARGV, RULESET_ARGV] SECRET_ARGV = ["gh", "secret", "set", rs.SEED_ENV, "--env", "release", "--repo", REPO] API_KWARGS = {"capture_output": True, "text": True, "encoding": "utf-8"} SECRET_KWARGS = dict(API_KWARGS, errors="replace") @@ -98,6 +102,28 @@ def canned_policies(*extra): {"id": 3 + i, "node_id": "n%d" % i, "name": n, "type": t} for i, (n, t) in enumerate(items)]} +def canned_rulesets(tag=None): + """The repository's ruleset list: a branch ruleset the check must skip, then the tag one; + `tag` edits the tag ruleset's summary.""" + summary = {"id": TAG_RULESET_ID, "name": "v-tags-admin-only", "target": "tag", + "enforcement": "active", "source_type": "Repository"} + summary.update(tag or {}) + return [{"id": 10, "name": "main-no-force-no-delete", "target": "branch", + "enforcement": "active", "source_type": "Repository"}, summary] + + +def canned_ruleset(**over): + """GitHub's REST answer for the v* tag ruleset, shaped as the live repository's.""" + ruleset = {"id": TAG_RULESET_ID, "name": "v-tags-admin-only", "target": "tag", + "enforcement": "active", + "conditions": {"ref_name": {"exclude": [], "include": ["refs/tags/v*"]}}, + "rules": [{"type": "creation"}, {"type": "update"}, {"type": "deletion"}], + "bypass_actors": [{"actor_id": 5, "actor_type": "RepositoryRole", + "bypass_mode": "always"}]} + ruleset.update(over) + return ruleset + + class Uncanned(Exception): """The code under test ran a command the fake has no answer for.""" @@ -105,9 +131,11 @@ class Uncanned(Exception): class FakeGh: """A runner that answers from canned values and records every call; it starts no process.""" - def __init__(self, environment=None, policies=None, secret=None): + def __init__(self, environment=None, policies=None, secret=None, rulesets=None, ruleset=None): self.environment = canned_env() if environment is None else environment self.policies = canned_policies() if policies is None else policies + self.rulesets = canned_rulesets() if rulesets is None else rulesets + self.ruleset = canned_ruleset() if ruleset is None else ruleset self.secret = secret or (lambda text: (0, GH_LINE, GH_WARNING)) self.calls = [] @@ -119,6 +147,10 @@ def __call__(self, argv, **kwargs): answer = self.environment elif argv == POLICIES_ARGV: answer = self.policies + elif argv == RULESETS_ARGV: + answer = self.rulesets + elif argv == RULESET_ARGV: + answer = self.ruleset else: raise Uncanned(" ".join(argv)) if isinstance(answer, (dict, list)): @@ -230,11 +262,10 @@ def need(name, ok): need("no exception (%s)" % r["raised"], r["raised"] is None) need("exit 0", r["rc"] == 0) need("stderr empty", r["stderr"] == "") - need("three calls, the secret last", - len(calls) == 3 and len(secrets) == 1 and calls[2] is secrets[0]) - need("the two protection reads, exact argv and kwargs", - [c[0] for c in calls[:2]] == [ENV_ARGV, POLICIES_ARGV] - and all(c[1] == API_KWARGS for c in calls[:2])) + need("five calls, the secret last", + len(calls) == 5 and len(secrets) == 1 and calls[4] is secrets[0]) + need("the four protection reads, exact argv and kwargs", + [c[0] for c in calls[:4]] == READS and all(c[1] == API_KWARGS for c in calls[:4])) need("the dictated gh secret set argv", bool(secrets) and secrets[0][0] == SECRET_ARGV) need("the secret set kwargs, exactly, and the input", bool(secrets) and "input" in secrets[0][1] @@ -361,6 +392,27 @@ def drill_keygen_refuses(tmp): {"id": 4, "node_id": "n4", "name": "main", "type": "branch"}]}, "branch policies"), "an environment that is not an object": ([1, 2], None, "admins can bypass"), } + unruled = { + "no tag ruleset": (canned_rulesets()[:1], None, "no active tag ruleset"), + "the tag ruleset only evaluated": (canned_rulesets({"enforcement": "evaluate"}), None, + "no active tag ruleset"), + "a ruleset list that is not a list": ({"id": 11}, None, "no active tag ruleset"), + "another tag pattern": (None, canned_ruleset(conditions={"ref_name": { + "exclude": [], "include": ["refs/tags/release-*"]}}), "the v* pattern"), + "an exclusion": (None, canned_ruleset(conditions={"ref_name": { + "exclude": ["refs/tags/v0*"], "include": ["refs/tags/v*"]}}), "the v* pattern"), + "no conditions": (None, canned_ruleset(conditions=None), "the v* pattern"), + "deletion allowed": (None, canned_ruleset(rules=[{"type": "creation"}, {"type": "update"}]), + "creation, update and deletion"), + "a write role may bypass": (None, canned_ruleset(bypass_actors=[ + {"actor_id": 5, "actor_type": "RepositoryRole", "bypass_mode": "always"}, + {"actor_id": 4, "actor_type": "RepositoryRole", "bypass_mode": "always"}]), + "admin-only bypass"), + "a bypass list that is absent": (None, {k: v for k, v in canned_ruleset().items() + if k != "bypass_actors"}, "admin-only bypass"), + } + unruled_text = ("release_sign: the release tags are not held by a ruleset as docs/release.md " + "sets it (%s)") gh_failed = { "the environment missing": (FakeGh(environment=(1, "", "gh: Not Found (HTTP 404)\nmore")), "release_sign: gh api failed: gh: Not Found (HTTP 404)"), @@ -368,9 +420,13 @@ def drill_keygen_refuses(tmp): "release_sign: gh is not installed or cannot run"), "a non-JSON answer": (FakeGh(policies=(0, "", "")), "release_sign: gh api failed (the answer is not JSON)"), + "the ruleset read failed": (FakeGh(ruleset=(1, "", "gh: Not Found (HTTP 404)")), + "release_sign: gh api failed: gh: Not Found (HTTP 404)"), } cases = [(name, FakeGh(env, pol), UNPROTECTED % check_name) for name, (env, pol, check_name) in refusals.items()] + cases += [(name, FakeGh(rulesets=lst, ruleset=one), unruled_text % check_name) + for name, (lst, one, check_name) in unruled.items()] cases += [(name, fake, text) for name, (fake, text) in gh_failed.items()] for name, fake, text in cases: r = drive(rs.cmd_keygen, fake, table) @@ -385,7 +441,7 @@ def drill_keygen_secret(tmp): r = drive(rs.cmd_keygen, fake, table) check("keygen secret fails: the message is dictated and holds nothing else", r["rc"] == 3 and r["out"] == "release_sign: gh secret set failed (exit 1)\n" - and r["stderr"] == "" and len(fake.calls) == 3) + and r["stderr"] == "" and len(fake.calls) == 5) fake = FakeGh(secret=lambda text: FileNotFoundError("gh")) r = drive(rs.cmd_keygen, fake, table) check("keygen secret set cannot run: exit 3", @@ -408,7 +464,7 @@ def loses_the_new_row(text): rs.read_rows = original check("keygen row read back: a row that does not is exit 3 and no secret is set", r["rc"] == 3 and r["out"] == "release_sign: the new row did not read back\n" - and r["stderr"] == "" and not fake.secret_calls() and len(fake.calls) == 2 + and r["stderr"] == "" and not fake.secret_calls() and len(fake.calls) == 4 and r["left"] == []) diff --git a/.github/workflows/release-core.yml b/.github/workflows/release-core.yml index 6f9272a8f..6fec3e1e9 100644 --- a/.github/workflows/release-core.yml +++ b/.github/workflows/release-core.yml @@ -1,6 +1,7 @@ -# release-core -- the release lane's brain, ALWAYS executed from main -# (the trampoline calls it @main; the recovery dispatch runs main's YAML by -# construction). THIN ORCHESTRATOR (design D3): every refusal / lint / +# release-core -- the release lane's brain, executed from main: the trampoline +# calls it @main, and a recovery dispatch runs the YAML of the ref it is +# dispatched on, which the ritual picks as main (the release environment admits +# only main and the admin-only v* tags). THIN ORCHESTRATOR (design D3): every refusal / lint / # predicate decision lives in .github/ci/*.ps1 executed from the MAIN # checkout (checkout#2) -- the self-check below guards the JUDGE, not just the # evidence. The run logs workflow_ref/workflow_sha as evidence (not a gate -- diff --git a/docs/release.md b/docs/release.md index d8a554e74..d423ee6bb 100644 --- a/docs/release.md +++ b/docs/release.md @@ -14,8 +14,9 @@ the checksum). The zip also carries `mod/dlls/main.dll.sig`, the release signature, made in the publish job with the `release` environment's key. The `release` environment holds the release key. It has the maintainer as its required reviewer, with self-review allowed and administrators unable to -bypass it, and it deploys only to tags `v*` and the branch `main`; `release_sign.py keygen` -refuses to set a key into an environment protected any other way. +bypass it, and it deploys only to tags `v*` and the branch `main`. A tag ruleset lets only an +administrator create, move or delete a `v*` tag, since such a tag reaches the key. `release_sign.py +keygen` refuses to set a key unless the environment and the ruleset both stand as described here. Build numbers are minted in one place, the append-only, human-written ledger `.github/ci/LEDGER.tsv`. A tag or a release page is a deletable platform object and a drift From c01fad411007c439a64630c4ed66632d58601ee6 Mon Sep 17 00:00:00 2001 From: pelmentor Date: Sat, 10 Oct 2026 16:05:24 +0600 Subject: [PATCH 110/306] [build] the workflow gate reads every shape a run script can take The gate took a run: value only as an inline line or a bare block scalar, so a value on the next line, over several lines, behind an anchor or tag, under a quoted key, in a flow mapping, or a github-script script: slipped past. A value is now the rest of the key's line plus every later line that is blank or indented deeper. Fixtures hold one step per shape, a clean pair for the next-line value and the dedent. Evidence: the gate passes the five workflows, fails main's with 21, and its fixtures fire on all 11 violating lines and none of the clean ones. Co-Authored-By: Claude Sonnet 5.5 --- .github/ci/workflow_expr_gate.py | 80 +++++++++++++++++++++++--------- 1 file changed, 57 insertions(+), 23 deletions(-) diff --git a/.github/ci/workflow_expr_gate.py b/.github/ci/workflow_expr_gate.py index 7e66fb367..99c00381c 100644 --- a/.github/ci/workflow_expr_gate.py +++ b/.github/ci/workflow_expr_gate.py @@ -1,13 +1,16 @@ #!/usr/bin/env python3 -"""workflow_expr_gate -- no ${{ }} expression is pasted into a run: script. +"""workflow_expr_gate -- no ${{ }} expression is pasted into a run: or script: value. -GitHub substitutes an expression into a run: script as TEXT before the shell parses it, so -a value that carries quotes or $( ) becomes code: a tag name, a dispatch input or a job -output turns into a command in whatever step holds it. A value reaches a script through -the step's env: and is read as a variable ($env:NAME, "$NAME"), where it stays data. +GitHub substitutes an expression into a run: script (and into actions/github-script's +script: JS) as TEXT before the shell or the interpreter parses it, so a value that carries +quotes or $( ) becomes code: a tag name, a dispatch input or a job output turns into a +command in whatever step holds it. A value reaches a script through the step's env: and is +read as a variable ($env:NAME, "$NAME", process.env.NAME), where it stays data. -FAILS on any ${{ inside a run: value, block or inline, in .github/workflows/*.yml and in -composite actions. A comment line inside the script counts: GitHub substitutes there too. +FAILS on any ${{ inside such a value, in .github/workflows/*.yml and in composite actions: +the rest of the key's line plus every following line that is blank or indented deeper (a +block, a folded scalar, an anchor or tag before it, a quoted scalar over lines, a value on +the next line), a quoted key, a flow mapping. A comment inside the script counts too. Every run first fires two fixtures (a violating workflow must fail, a clean one must pass); if either does not hold, the gate cannot be trusted and exits 2. @@ -19,29 +22,29 @@ import sys ROOT = pathlib.Path(__file__).resolve().parents[2] -RUN_KEY = re.compile(r"^(?P\s*(?:-\s+)?)run:(?P.*)$") -BLOCK = re.compile(r"^\s*[|>][-+0-9]*\s*(#.*)?$") +SCRIPT_KEY = re.compile(r"^(?P\s*(?:-\s+)?)[\"']?(?:run|script)[\"']?\s*:(?P.*)$") +FLOW_KEY = re.compile(r"[{,]\s*[\"']?(?:run|script)[\"']?\s*:") def violations(text): - """(line number, line) of every ${{ inside a run: value of one YAML file.""" + """(line number, line) of every ${{ inside a run:/script: value of one YAML file.""" found = [] - block_indent = None + column = None for no, line in enumerate(text.splitlines(), 1): - if block_indent is not None: - body = line.strip() - if body and len(line) - len(line.lstrip()) <= block_indent: - block_indent = None - else: + if column is not None: + if not line.strip() or len(line) - len(line.lstrip()) > column: if "${{" in line: found.append((no, line.strip())) continue - m = RUN_KEY.match(line) - if not m: + column = None + m = SCRIPT_KEY.match(line) + if m: + column = len(m.group("lead")) + if "${{" in m.group("rest"): + found.append((no, line.strip())) continue - if BLOCK.match(m.group("rest")): - block_indent = len(m.group("lead")) - elif "${{" in m.group("rest"): + flow = FLOW_KEY.search(line) + if flow and "${{" in line[flow.end():]: found.append((no, line.strip())) return found @@ -57,7 +60,29 @@ def violations(text): # ${{ secrets.K }} in a comment is substituted too env: OK: ${{ inputs.y }} + - name: value on the next line + run: + echo ${{ a }} + - name: quoted scalar over two lines + run: "echo + ${{ a }}" + - name: space before the colon + run : echo ${{ a }} + - name: quoted key + "run": echo ${{ b }} + - name: tag before the block + run: !!str | + echo ${{ a }} + - name: anchor before the block + run: &x | + echo ${{ a }} + - {name: n, run: "echo ${{ c }}"} + - uses: actions/github-script@0 + with: + script: | + const t = '${{ inputs.tag }}'; """ +FIXTURE_BAD_LINES = [5, 8, 9, 14, 17, 19, 21, 24, 27, 28, 32] FIXTURE_GOOD = """jobs: a: runs-on: ${{ matrix.os }} @@ -68,6 +93,15 @@ def violations(text): TAG: ${{ inputs.tag }} run: | Write-Host "tag $env:TAG" + - name: value on the next line, read through env + env: + X: ${{ inputs.x }} + run: + Write-Host $env:X + - name: a dedent ends the value + run: | + Write-Host ok + if: ${{ x }} - uses: actions/cache@0 with: key: k-${{ hashFiles('x') }} @@ -77,8 +111,8 @@ def violations(text): def fixtures_hold(): bad = [no for no, _ in violations(FIXTURE_BAD)] good = violations(FIXTURE_GOOD) - if bad != [5, 8, 9]: - print(f"workflow_expr_gate: FIXTURE the violating workflow fired on lines {bad}, expected [5, 8, 9]") + if bad != FIXTURE_BAD_LINES: + print(f"workflow_expr_gate: FIXTURE the violating workflow fired on lines {bad}, expected {FIXTURE_BAD_LINES}") return False if good: print(f"workflow_expr_gate: FIXTURE the clean workflow fired: {good}") From e291ba477c72205cb8fe49a720f8debbf3e5e3af Mon Sep 17 00:00:00 2001 From: pelmentor Date: Sat, 10 Oct 2026 16:09:04 +0600 Subject: [PATCH 111/306] [build] keygen also holds main to admin-only pushes The release environment admits main as well as the v* tags, so whoever may push to main reaches the key, yet keygen read only the tag ruleset. It now reads every active tag and branch ruleset and refuses unless creation, update and deletion of v* tags and updates of main are each refused to all but the admin role, naming the first requirement that is not held. The drill models the live repository's three rulesets (renumbered 10 to 12) and 15 refusal arms; the docs say two rulesets stand, and a recovery dispatch runs on main. Evidence: keygen drill 73 checks and signer drill 134 pass; the check accepts the live rulesets (v-tags-admin-only, main-push-admin-only). Co-Authored-By: Claude Sonnet 5.5 --- .github/ci/release_sign.py | 70 +++++----- .github/ci/release_sign_keygen_drill.py | 177 ++++++++++++++++-------- docs/release.md | 9 +- 3 files changed, 159 insertions(+), 97 deletions(-) diff --git a/.github/ci/release_sign.py b/.github/ci/release_sign.py index c5b98362a..d28076cce 100644 --- a/.github/ci/release_sign.py +++ b/.github/ci/release_sign.py @@ -10,7 +10,8 @@ build it belongs to and an Ed25519 signature over `signed_message`. `verify` applies the same grammar and steps as the game, and prints which step refused. `keygen` makes a key in memory, hands its private half straight to the `release` environment's secret through `gh`, and prints only the -table row; it refuses first unless that environment is protected as docs/release.md sets it. +table row; it refuses first unless that environment, and the rulesets over the refs it admits, +are set as docs/release.md sets them. The game's check is the vendored ed25519-donna `ed25519_sign_open` (src/votv-coop/third_party/GameNetworkingSockets/src/external/ed25519-donna/ed25519.c); `verify` is the same cofactorless, byte-exact check (RFC 8032 section 5.1.7 allows it): it @@ -31,7 +32,7 @@ environment only, and no message and no exception text this file writes holds it. Exit codes: 0 done; 1 verification failed; 2 a usage or key-table error; 3 the environment (the -seed, or `gh` and the repository's `release` environment). +seed, or `gh`, the repository's `release` environment and its rulesets). Every message is one line on stdout and starts `release_sign: `. """ import argparse @@ -426,48 +427,51 @@ def _check_protection(environment, policies): raise _unprotected("branch policies") -# The release environment admits the v* tags, so whoever may create or move one reaches the key. -# Only an admin may: an active tag ruleset over refs/tags/v* with no exclusion, refusing creation, -# update and deletion to all but the admin repository role (actor 5, as the live ruleset reads). -_TAG_PATTERN = "refs/tags/v*" -_TAG_RULES = {"creation", "update", "deletion"} +# The release environment admits the v* tags and main, so whoever may create or move such a tag, +# or push to main, reaches the key. Only an admin may: for each requirement an active ruleset over +# that ref with no exclusion, refusing the rule to all but the admin repository role (actor 5, as +# the live rulesets read). The rulesets are read in list order and any one may hold a requirement. _ADMIN_ROLE = ("RepositoryRole", 5) +_REQUIREMENTS = (("tag", "refs/tags/v*", "creation"), ("tag", "refs/tags/v*", "update"), + ("tag", "refs/tags/v*", "deletion"), ("branch", "refs/heads/main", "update")) -def _unruled(check): - return SignError("protection", 3, "release_sign: the release tags are not held by a ruleset as " - "docs/release.md sets it (%s)" % check) +def _unruled(pattern, rule): + return SignError("protection", 3, "release_sign: the refs the release environment admits are " + "not held by rulesets as docs/release.md sets it (%s %s)" % (pattern, rule)) -def _tag_ruleset_flaw(ruleset): - """Why one ruleset does not hold the release tags, or None when it does.""" +def _ruleset_holds(ruleset, pattern, rule): + """Whether one ruleset refuses `rule` on `pattern` to all but the admin repository role.""" conditions = ruleset.get("conditions") if isinstance(ruleset, dict) else None names = conditions.get("ref_name") if isinstance(conditions, dict) else None if not (isinstance(names, dict) and isinstance(names.get("include"), list) - and _TAG_PATTERN in names["include"] and names.get("exclude") == []): - return "the v* pattern" + and pattern in names["include"] and names.get("exclude") == []): + return False rules = ruleset.get("rules") kinds = {r.get("type") for r in rules if isinstance(r, dict)} if isinstance(rules, list) else set() - if not _TAG_RULES <= kinds: - return "creation, update and deletion" + if rule not in kinds: + return False bypass = ruleset.get("bypass_actors") - if not isinstance(bypass, list) or any( - not isinstance(a, dict) or (a.get("actor_type"), a.get("actor_id")) != _ADMIN_ROLE - for a in bypass): - return "admin-only bypass" - return None + return isinstance(bypass, list) and all( + isinstance(a, dict) and (a.get("actor_type"), a.get("actor_id")) == _ADMIN_ROLE + for a in bypass) -def _check_tag_ruleset(runner, repo): - listed = _gh_api(runner, "repos/%s/rulesets" % repo) - ids = [r.get("id") for r in listed if isinstance(r, dict) and r.get("target") == "tag" - and r.get("enforcement") == "active"] if isinstance(listed, list) else [] - ids = [i for i in ids if isinstance(i, int) and not isinstance(i, bool)] - if not ids: - raise _unruled("no active tag ruleset") - flaws = [_tag_ruleset_flaw(_gh_api(runner, "repos/%s/rulesets/%d" % (repo, i))) for i in ids] - if None not in flaws: - raise _unruled(flaws[0]) +def _check_ref_rulesets(runner, repo): + listed = _gh_api(runner, "repos/%s/rulesets?per_page=100" % repo) + read = [] + for summary in listed if isinstance(listed, list) else []: + if not (isinstance(summary, dict) and summary.get("enforcement") == "active" + and summary.get("target") in ("tag", "branch")): + continue + ruleset_id = summary.get("id") + if isinstance(ruleset_id, int) and not isinstance(ruleset_id, bool): + read.append((summary["target"], + _gh_api(runner, "repos/%s/rulesets/%d" % (repo, ruleset_id)))) + for target, pattern, rule in _REQUIREMENTS: + if not any(t == target and _ruleset_holds(r, pattern, rule) for t, r in read): + raise _unruled(pattern, rule) def _key_id(text): @@ -479,7 +483,7 @@ def _key_id(text): def cmd_keygen(args, runner, out): - """The order is the custody: refuse an unprotected environment or unheld release tags, then + """The order is the custody: refuse an unprotected environment or unheld release refs, then make the key, check its row, hand the seed to gh's standard input, and print the row. No file is written.""" if _REPO.fullmatch(args.repo) is None: @@ -494,7 +498,7 @@ def cmd_keygen(args, runner, out): environment = _gh_api(runner, base) policies = _gh_api(runner, base + "/deployment-branch-policies") _check_protection(environment, policies) - _check_tag_ruleset(runner, args.repo) + _check_ref_rulesets(runner, args.repo) seed = os.urandom(32) pub = ed25519_public(seed) diff --git a/.github/ci/release_sign_keygen_drill.py b/.github/ci/release_sign_keygen_drill.py index 4aed64a64..681aad0b0 100644 --- a/.github/ci/release_sign_keygen_drill.py +++ b/.github/ci/release_sign_keygen_drill.py @@ -4,7 +4,7 @@ Standard library only. A fake runner answers canned `gh` output and records every call's argv and keyword arguments, so no process starts and the real `gh` is never run; each run happens inside a temporary working directory with stdout and stderr captured. The fake answers only the -commands keygen may run (four reads, then the secret), with the output a real `gh` prints (so an echo of it is seen), and acts +commands keygen may run (its reads, then the secret), with the output a real `gh` prints (so an echo of it is seen), and acts on the keyword arguments the way `subprocess.run` does. Three mutants are always run and the same assertion helper as the green arms must flag each of them; one starts a process, which the guard below must refuse. @@ -61,10 +61,17 @@ REPO = "drill-owner/drill-repo" ENV_ARGV = ["gh", "api", "repos/%s/environments/release" % REPO] POLICIES_ARGV = ["gh", "api", "repos/%s/environments/release/deployment-branch-policies" % REPO] -RULESETS_ARGV = ["gh", "api", "repos/%s/rulesets" % REPO] -TAG_RULESET_ID = 11 -RULESET_ARGV = ["gh", "api", "repos/%s/rulesets/%d" % (REPO, TAG_RULESET_ID)] -READS = [ENV_ARGV, POLICIES_ARGV, RULESETS_ARGV, RULESET_ARGV] +RULESETS_ARGV = ["gh", "api", "repos/%s/rulesets?per_page=100" % REPO] +BRANCH_RULESET_ID, TAG_RULESET_ID, MAIN_RULESET_ID = 10, 11, 12 +DETAIL_PREFIX = "repos/%s/rulesets/" % REPO + + +def ruleset_argv(ruleset_id): + return ["gh", "api", DETAIL_PREFIX + str(ruleset_id)] + + +READS = [ENV_ARGV, POLICIES_ARGV, RULESETS_ARGV, ruleset_argv(BRANCH_RULESET_ID), + ruleset_argv(TAG_RULESET_ID), ruleset_argv(MAIN_RULESET_ID)] SECRET_ARGV = ["gh", "secret", "set", rs.SEED_ENV, "--env", "release", "--repo", REPO] API_KWARGS = {"capture_output": True, "text": True, "encoding": "utf-8"} SECRET_KWARGS = dict(API_KWARGS, errors="replace") @@ -102,26 +109,52 @@ def canned_policies(*extra): {"id": 3 + i, "node_id": "n%d" % i, "name": n, "type": t} for i, (n, t) in enumerate(items)]} -def canned_rulesets(tag=None): - """The repository's ruleset list: a branch ruleset the check must skip, then the tag one; - `tag` edits the tag ruleset's summary.""" - summary = {"id": TAG_RULESET_ID, "name": "v-tags-admin-only", "target": "tag", - "enforcement": "active", "source_type": "Repository"} - summary.update(tag or {}) - return [{"id": 10, "name": "main-no-force-no-delete", "target": "branch", - "enforcement": "active", "source_type": "Repository"}, summary] - - -def canned_ruleset(**over): - """GitHub's REST answer for the v* tag ruleset, shaped as the live repository's.""" - ruleset = {"id": TAG_RULESET_ID, "name": "v-tags-admin-only", "target": "tag", - "enforcement": "active", - "conditions": {"ref_name": {"exclude": [], "include": ["refs/tags/v*"]}}, - "rules": [{"type": "creation"}, {"type": "update"}, {"type": "deletion"}], - "bypass_actors": [{"actor_id": 5, "actor_type": "RepositoryRole", - "bypass_mode": "always"}]} - ruleset.update(over) - return ruleset +ADMIN_BYPASS = {"actor_id": 5, "actor_type": "RepositoryRole", "bypass_mode": "always"} +WRITE_BYPASS = {"actor_id": 4, "actor_type": "RepositoryRole", "bypass_mode": "always"} +# id -> (target, name, ref pattern, rule types, bypass actors): the live repository's rulesets +# 10 to 12 in its order, and a second v* ruleset (13) for the arm that reads two. +RULESET_SHAPES = { + BRANCH_RULESET_ID: ("branch", "main-no-force-no-delete", "refs/heads/main", + ["deletion", "non_fast_forward"], []), + TAG_RULESET_ID: ("tag", "v-tags-admin-only", "refs/tags/v*", + ["creation", "update", "deletion"], [ADMIN_BYPASS]), + MAIN_RULESET_ID: ("branch", "main-push-admin-only", "refs/heads/main", ["update"], + [ADMIN_BYPASS]), + 13: ("tag", "v-tags-second", "refs/tags/v*", ["creation", "update", "deletion"], + [ADMIN_BYPASS]), +} + + +def dropping_absent(values): + return {k: v for k, v in values.items() if v is not ABSENT} + + +def canned_rulesets(ids=(BRANCH_RULESET_ID, TAG_RULESET_ID, MAIN_RULESET_ID), edit=None, drop=()): + """The repository's ruleset list, in the order of `ids` less `drop`; `edit` maps an id to + overrides of its summary.""" + items = [] + for ruleset_id in ids: + if ruleset_id in drop: + continue + target, name = RULESET_SHAPES[ruleset_id][:2] + summary = {"id": ruleset_id, "name": name, "target": target, "enforcement": "active", + "source_type": "Repository"} + summary.update((edit or {}).get(ruleset_id, {})) + items.append(summary) + return items + + +def canned_details(edit=None): + """GitHub's REST answer for each ruleset, by id, shaped as the live repository's; `edit` + maps an id to overrides (a value of ABSENT drops that key).""" + details = {} + for ruleset_id, (target, name, pattern, rules, bypass) in RULESET_SHAPES.items(): + detail = {"id": ruleset_id, "name": name, "target": target, "enforcement": "active", + "conditions": {"ref_name": {"exclude": [], "include": [pattern]}}, + "rules": [{"type": t} for t in rules], "bypass_actors": list(bypass)} + detail.update((edit or {}).get(ruleset_id, {})) + details[ruleset_id] = dropping_absent(detail) + return details class Uncanned(Exception): @@ -131,11 +164,11 @@ class Uncanned(Exception): class FakeGh: """A runner that answers from canned values and records every call; it starts no process.""" - def __init__(self, environment=None, policies=None, secret=None, rulesets=None, ruleset=None): + def __init__(self, environment=None, policies=None, secret=None, rulesets=None, details=None): self.environment = canned_env() if environment is None else environment self.policies = canned_policies() if policies is None else policies self.rulesets = canned_rulesets() if rulesets is None else rulesets - self.ruleset = canned_ruleset() if ruleset is None else ruleset + self.details = canned_details() if details is None else details self.secret = secret or (lambda text: (0, GH_LINE, GH_WARNING)) self.calls = [] @@ -149,8 +182,10 @@ def __call__(self, argv, **kwargs): answer = self.policies elif argv == RULESETS_ARGV: answer = self.rulesets - elif argv == RULESET_ARGV: - answer = self.ruleset + elif (len(argv) == 3 and argv[:2] == ["gh", "api"] and argv[2].startswith(DETAIL_PREFIX) + and argv[2][len(DETAIL_PREFIX):].isdigit() + and int(argv[2][len(DETAIL_PREFIX):]) in self.details): + answer = self.details[int(argv[2][len(DETAIL_PREFIX):])] else: raise Uncanned(" ".join(argv)) if isinstance(answer, (dict, list)): @@ -247,9 +282,10 @@ def no_process(): rs.subprocess, rs.os = real_sub, real_os -def keygen_flaws(keygen, fake, table, key_id=2, seeds=None): +def keygen_flaws(keygen, fake, table, key_id=2, seeds=None, reads=READS): """Every custody rule a green keygen run must keep; an empty list is a clean run. `key_id` is - the id the run asks for; the seed each run hands to gh is appended to `seeds`.""" + the id the run asks for; the seed each run hands to gh is appended to `seeds`; `reads` is the + exact list of reads the run makes before the secret.""" table_before = table.read_bytes() r = drive(keygen, fake, table, key_id=str(key_id)) flaws = [] @@ -262,10 +298,11 @@ def need(name, ok): need("no exception (%s)" % r["raised"], r["raised"] is None) need("exit 0", r["rc"] == 0) need("stderr empty", r["stderr"] == "") - need("five calls, the secret last", - len(calls) == 5 and len(secrets) == 1 and calls[4] is secrets[0]) - need("the four protection reads, exact argv and kwargs", - [c[0] for c in calls[:4]] == READS and all(c[1] == API_KWARGS for c in calls[:4])) + last = len(reads) + need("%d calls, the secret last" % (last + 1), + len(calls) == last + 1 and len(secrets) == 1 and calls[last] is secrets[0]) + need("the protection reads, exact argv and kwargs", + [c[0] for c in calls[:last]] == reads and all(c[1] == API_KWARGS for c in calls[:last])) need("the dictated gh secret set argv", bool(secrets) and secrets[0][0] == SECRET_ARGV) need("the secret set kwargs, exactly, and the input", bool(secrets) and "input" in secrets[0][1] @@ -346,8 +383,16 @@ def drill_keygen_green(tmp): ("--id 254 through main", via_main, FakeGh(), 254)): flaws = keygen_flaws(keygen, fake, table, key_id, seeds) check("keygen green with " + name + (": " + ", ".join(flaws) if flaws else ""), not flaws) + excluded = {"conditions": {"ref_name": {"exclude": ["refs/tags/v0*"], + "include": ["refs/tags/v*"]}}} + two_tag_rulesets = FakeGh(rulesets=canned_rulesets(ids=(10, 11, 12, 13)), + details=canned_details({TAG_RULESET_ID: excluded})) + flaws = keygen_flaws(rs.cmd_keygen, two_tag_rulesets, table, 2, seeds, + reads=READS + [ruleset_argv(13)]) + check("keygen green with two tag rulesets, the first listed flawed and the second holding" + + (": " + ", ".join(flaws) if flaws else ""), not flaws) check("keygen green: every run's seed is its own and none is the public selftest seed", - len(seeds) == 10 and len(set(seeds)) == len(seeds) and rs.TEST_SEED_HEX not in seeds) + len(seeds) == 11 and len(set(seeds)) == len(seeds) and rs.TEST_SEED_HEX not in seeds) def drill_keygen_mutants(tmp): @@ -392,27 +437,38 @@ def drill_keygen_refuses(tmp): {"id": 4, "node_id": "n4", "name": "main", "type": "branch"}]}, "branch policies"), "an environment that is not an object": ([1, 2], None, "admins can bypass"), } + # Each: (the ruleset list or None, edits to the ruleset details or None, the requirement the + # refusal must name). The first requirement a ruleset breaks is the tag's creation. + creation = ("refs/tags/v*", "creation") + main_update = ("refs/heads/main", "update") + tag_rules = lambda *kinds: {TAG_RULESET_ID: {"rules": [{"type": k} for k in kinds]}} + tag_bypass = lambda *actors: {TAG_RULESET_ID: {"bypass_actors": list(actors)}} unruled = { - "no tag ruleset": (canned_rulesets()[:1], None, "no active tag ruleset"), - "the tag ruleset only evaluated": (canned_rulesets({"enforcement": "evaluate"}), None, - "no active tag ruleset"), - "a ruleset list that is not a list": ({"id": 11}, None, "no active tag ruleset"), - "another tag pattern": (None, canned_ruleset(conditions={"ref_name": { - "exclude": [], "include": ["refs/tags/release-*"]}}), "the v* pattern"), - "an exclusion": (None, canned_ruleset(conditions={"ref_name": { - "exclude": ["refs/tags/v0*"], "include": ["refs/tags/v*"]}}), "the v* pattern"), - "no conditions": (None, canned_ruleset(conditions=None), "the v* pattern"), - "deletion allowed": (None, canned_ruleset(rules=[{"type": "creation"}, {"type": "update"}]), - "creation, update and deletion"), - "a write role may bypass": (None, canned_ruleset(bypass_actors=[ - {"actor_id": 5, "actor_type": "RepositoryRole", "bypass_mode": "always"}, - {"actor_id": 4, "actor_type": "RepositoryRole", "bypass_mode": "always"}]), - "admin-only bypass"), - "a bypass list that is absent": (None, {k: v for k, v in canned_ruleset().items() - if k != "bypass_actors"}, "admin-only bypass"), + "no tag ruleset": (canned_rulesets(drop=(TAG_RULESET_ID,)), None, creation), + "the tag ruleset only evaluated": (canned_rulesets( + edit={TAG_RULESET_ID: {"enforcement": "evaluate"}}), None, creation), + "a ruleset list that is not a list": ({"id": 11}, None, creation), + "another tag pattern": (None, {TAG_RULESET_ID: {"conditions": {"ref_name": { + "exclude": [], "include": ["refs/tags/release-*"]}}}}, creation), + "an exclusion": (None, {TAG_RULESET_ID: {"conditions": {"ref_name": { + "exclude": ["refs/tags/v0*"], "include": ["refs/tags/v*"]}}}}, creation), + "no conditions": (None, {TAG_RULESET_ID: {"conditions": None}}, creation), + "creation allowed": (None, tag_rules("update", "deletion"), creation), + "update allowed": (None, tag_rules("creation", "deletion"), ("refs/tags/v*", "update")), + "deletion allowed": (None, tag_rules("creation", "update"), ("refs/tags/v*", "deletion")), + "a write role may bypass": (None, tag_bypass(ADMIN_BYPASS, WRITE_BYPASS), creation), + "an OrganizationAdmin with id 5": (None, tag_bypass( + dict(ADMIN_BYPASS, actor_type="OrganizationAdmin")), creation), + "a bypass list that is absent": (None, {TAG_RULESET_ID: {"bypass_actors": ABSENT}}, + creation), + "a boolean id on the tag summary": (canned_rulesets(edit={TAG_RULESET_ID: {"id": True}}), + None, creation), + "main pushes open": (canned_rulesets(drop=(MAIN_RULESET_ID,)), None, main_update), + "main's ruleset lets a write role bypass": (None, {MAIN_RULESET_ID: { + "bypass_actors": [ADMIN_BYPASS, WRITE_BYPASS]}}, main_update), } - unruled_text = ("release_sign: the release tags are not held by a ruleset as docs/release.md " - "sets it (%s)") + unruled_text = ("release_sign: the refs the release environment admits are not held by " + "rulesets as docs/release.md sets it (%s %s)") gh_failed = { "the environment missing": (FakeGh(environment=(1, "", "gh: Not Found (HTTP 404)\nmore")), "release_sign: gh api failed: gh: Not Found (HTTP 404)"), @@ -420,13 +476,14 @@ def drill_keygen_refuses(tmp): "release_sign: gh is not installed or cannot run"), "a non-JSON answer": (FakeGh(policies=(0, "", "")), "release_sign: gh api failed (the answer is not JSON)"), - "the ruleset read failed": (FakeGh(ruleset=(1, "", "gh: Not Found (HTTP 404)")), + "a ruleset read failed": (FakeGh(details={ + **canned_details(), BRANCH_RULESET_ID: (1, "", "gh: Not Found (HTTP 404)")}), "release_sign: gh api failed: gh: Not Found (HTTP 404)"), } cases = [(name, FakeGh(env, pol), UNPROTECTED % check_name) for name, (env, pol, check_name) in refusals.items()] - cases += [(name, FakeGh(rulesets=lst, ruleset=one), unruled_text % check_name) - for name, (lst, one, check_name) in unruled.items()] + cases += [(name, FakeGh(rulesets=lst, details=canned_details(edit)), unruled_text % required) + for name, (lst, edit, required) in unruled.items()] cases += [(name, fake, text) for name, (fake, text) in gh_failed.items()] for name, fake, text in cases: r = drive(rs.cmd_keygen, fake, table) @@ -441,7 +498,7 @@ def drill_keygen_secret(tmp): r = drive(rs.cmd_keygen, fake, table) check("keygen secret fails: the message is dictated and holds nothing else", r["rc"] == 3 and r["out"] == "release_sign: gh secret set failed (exit 1)\n" - and r["stderr"] == "" and len(fake.calls) == 5) + and r["stderr"] == "" and len(fake.calls) == 7) fake = FakeGh(secret=lambda text: FileNotFoundError("gh")) r = drive(rs.cmd_keygen, fake, table) check("keygen secret set cannot run: exit 3", @@ -464,7 +521,7 @@ def loses_the_new_row(text): rs.read_rows = original check("keygen row read back: a row that does not is exit 3 and no secret is set", r["rc"] == 3 and r["out"] == "release_sign: the new row did not read back\n" - and r["stderr"] == "" and not fake.secret_calls() and len(fake.calls) == 4 + and r["stderr"] == "" and not fake.secret_calls() and len(fake.calls) == 6 and r["left"] == []) diff --git a/docs/release.md b/docs/release.md index d423ee6bb..cb24f5221 100644 --- a/docs/release.md +++ b/docs/release.md @@ -14,9 +14,10 @@ the checksum). The zip also carries `mod/dlls/main.dll.sig`, the release signature, made in the publish job with the `release` environment's key. The `release` environment holds the release key. It has the maintainer as its required reviewer, with self-review allowed and administrators unable to -bypass it, and it deploys only to tags `v*` and the branch `main`. A tag ruleset lets only an -administrator create, move or delete a `v*` tag, since such a tag reaches the key. `release_sign.py -keygen` refuses to set a key unless the environment and the ruleset both stand as described here. +bypass it, and it deploys only to tags `v*` and the branch `main`. Two rulesets hold those refs: +only an administrator may create, move or delete a `v*` tag or push to `main`, since either +reaches the key. `release_sign.py keygen` refuses to set a key unless the environment and both +rulesets stand as described here. Build numbers are minted in one place, the append-only, human-written ledger `.github/ci/LEDGER.tsv`. A tag or a release page is a deletable platform object and a drift @@ -65,7 +66,7 @@ release is fired as the last thing; its publish job waits for the maintainer's a tag reach the remote together and the number is unique there from that moment. 6. **Watch the run to green.** The judge refuses with a labelled verdict on any missed precondition; a refusal is stateless, so fix the cause and re-run (dispatch `release-core` - with the tag). The publish job waits for the maintainer's approval of the `release` + on `main` with the tag). The publish job waits for the maintainer's approval of the `release` environment; a run waiting for that approval still holds the `release` concurrency group, so a re-run queues behind it until the deployment is approved or rejected. A run whose environment has no key fails when that job starts. The release is From 520ce3eb488341e87d5501b002580687310081dd Mon Sep 17 00:00:00 2001 From: pelmentor Date: Sat, 10 Oct 2026 16:10:15 +0600 Subject: [PATCH 112/306] [build] publish picks the tag's commit with a tested pure function The tag re-read inside publish had no test and ran against the checkout's origin remote. The pick of a tag's commit from the ls-remote lines (peeled line over the tag line, exact case-sensitive ref names, null when absent) is now a function in the ledger library, and publish reads the repository by URL. The tag grammar ends in \z so a trailing newline is refused. The selftest holds both, and the repo gates run it. Its checkout no longer persists credentials. Evidence: tag_regex_selftest holds every row, the pick rows included; ledger_lint_drill 12 of 12; ledger_lint -SkipApi 0 FAIL. Co-Authored-By: Claude Sonnet 5.5 --- .github/ci/ledger_lib.ps1 | 17 ++++++++++++++++- .github/ci/publish.ps1 | 14 ++++---------- .github/ci/tag_regex_selftest.ps1 | 14 +++++++++++++- .github/workflows/repo-gates.yml | 5 +++++ 4 files changed, 38 insertions(+), 12 deletions(-) diff --git a/.github/ci/ledger_lib.ps1 b/.github/ci/ledger_lib.ps1 index 085af5862..22c560400 100644 --- a/.github/ci/ledger_lib.ps1 +++ b/.github/ci/ledger_lib.ps1 @@ -7,7 +7,7 @@ Set-StrictMode -Version Latest # ONE tag grammar for the whole lane: v-b[-dev]. # = d.d.d + optional single letter (e.g. 0.9.0n); = decimal, no leading zero. -$script:TagRegex = '^v(?\d+\.\d+\.\d+[a-z]?)-b(?[1-9]\d*)(?-dev)?$' +$script:TagRegex = '^v(?\d+\.\d+\.\d+[a-z]?)-b(?[1-9]\d*)(?-dev)?\z' # \z: '$' would admit one trailing newline # Repo-relative path of the wire-revision header (kProtocolVersion) -- the same # file CMakeLists regex-parses for the build number. @@ -15,6 +15,21 @@ $script:ProtocolHeaderPath = 'src/votv-coop/include/coop/net/protocol.h' function Get-ReleaseTagRegex { $script:TagRegex } +# The commit a tag names, from `git ls-remote --tags` output: the peeled line +# (refs/tags/^{}) of an annotated tag wins over the tag line itself. Ref +# names match exact and case-sensitive, so a near-name (0) never answers. +# $null when neither line is listed. +function Select-TagCommitFromLsRemote { + param([string[]]$Lines, [Parameter(Mandatory)][string]$TagName) + $direct = $null; $peeled = $null + foreach ($line in $Lines) { + $sha, $ref = "$line" -split "`t", 2 + if ($ref -ceq "refs/tags/$TagName") { $direct = $sha } + elseif ($ref -ceq "refs/tags/$TagName^{}") { $peeled = $sha } + } + if ($peeled) { $peeled } else { $direct } +} + function ConvertFrom-ReleaseTag { param([Parameter(Mandatory)][string]$TagName) if ($TagName -cnotmatch $script:TagRegex) { return $null } # -c: the grammar is case-SENSITIVE (PS -match is not) diff --git a/.github/ci/publish.ps1 b/.github/ci/publish.ps1 index 70e7f552b..335372043 100644 --- a/.github/ci/publish.ps1 +++ b/.github/ci/publish.ps1 @@ -25,18 +25,12 @@ if (-not $tag) { throw "tag '$TagName' fails the grammar (the judge should have if ($TagSha -cnotmatch '^[0-9a-f]{40}$') { throw "TagSha '$TagSha' is not a full lowercase commit sha" } # The release hangs on the tag NAME, its assets on TagSha. The tag is re-read from -# origin before the draft and again right before the flip makes the release public: +# the repository before the draft and again right before the flip makes the release public: # a tag moved after the judge would publish these assets under another commit. function Assert-TagStillNamesJudgedSha { - $lines = @(git ls-remote --tags origin "refs/tags/$TagName*") - if ($LASTEXITCODE -ne 0) { throw "re-reading tag '$TagName' from origin failed" } - $direct = $null; $peeled = $null - foreach ($line in $lines) { - $sha, $ref = "$line" -split "`t", 2 - if ($ref -ceq "refs/tags/$TagName") { $direct = $sha } - elseif ($ref -ceq "refs/tags/$TagName^{}") { $peeled = $sha } - } - $now = if ($peeled) { $peeled } else { $direct } + $lines = @(git ls-remote --tags "https://github.com/$Repo.git" "refs/tags/$TagName*") + if ($LASTEXITCODE -ne 0) { throw "re-reading tag '$TagName' from $Repo failed" } + $now = Select-TagCommitFromLsRemote -Lines $lines -TagName $TagName if ($now -cne $TagSha) { throw "tag '$TagName' now names '$now', not the judged $TagSha -- refusing to publish" } Write-Host "tag $TagName still names the judged $TagSha" } diff --git a/.github/ci/tag_regex_selftest.ps1 b/.github/ci/tag_regex_selftest.ps1 index 25e190c7e..c44d853f9 100644 --- a/.github/ci/tag_regex_selftest.ps1 +++ b/.github/ci/tag_regex_selftest.ps1 @@ -34,7 +34,8 @@ $mustRefuse = @( 'v0.9.0n-b012', # leading zero 'v0.9.0n-b0', # zero build 'v0.9.0n-b12-DEV', # case matters - 'v0.9.0n-b12 ' # trailing whitespace + 'v0.9.0n-b12 ', # trailing whitespace + "v0.9.0n-b12`n" # trailing newline ('$' would admit it, '\z' does not) ) foreach ($t in $mustRefuse) { $p = ConvertFrom-ReleaseTag $t @@ -53,6 +54,17 @@ Assert-Case (@(Test-ReleaseNotesFormat -Content "## What's new`n- x").Count -gt Assert-Case (@(Test-ReleaseNotesFormat -Content "- fine`nsource: 0123456789abcdef0123456789abcdef01234567").Count -gt 0) 'notes: source:-grammar line refused (first-match shadowing)' Assert-Case (@(Test-ReleaseNotesFormat -Content ("- fine`nsha256: " + ('a' * 64) + ' x.dll')).Count -gt 0) 'notes: sha256:-grammar line refused' +# --- Tag-commit pick (publish's re-read of the tag on origin) ---------------- +# The pure function over `git ls-remote --tags` lines: a lightweight tag is its +# own line; an annotated one adds a peeled ^{} line whose sha is the commit. +$shaA = 'a' * 40; $shaB = 'b' * 40; $shaC = 'c' * 40 +Assert-Case ((Select-TagCommitFromLsRemote -Lines @("$shaA`trefs/tags/v1.0.0-b5") -TagName 'v1.0.0-b5') -ceq $shaA) 'pick: a lightweight tag names its own sha' +Assert-Case ((Select-TagCommitFromLsRemote -Lines @("$shaB`trefs/tags/v1.0.0-b5", "$shaA`trefs/tags/v1.0.0-b5^{}") -TagName 'v1.0.0-b5') -ceq $shaA) 'pick: an annotated tag names the peeled commit' +Assert-Case ((Select-TagCommitFromLsRemote -Lines @("$shaA`trefs/tags/v1.0.0-b5^{}", "$shaB`trefs/tags/v1.0.0-b5") -TagName 'v1.0.0-b5') -ceq $shaA) 'pick: the peeled line wins whichever line comes first' +Assert-Case ((Select-TagCommitFromLsRemote -Lines @("$shaC`trefs/tags/v1.0.0-b50", "$shaA`trefs/tags/v1.0.0-b5") -TagName 'v1.0.0-b5') -ceq $shaA) 'pick: a near-name line (b50) beside b5 is ignored' +Assert-Case ($null -eq (Select-TagCommitFromLsRemote -Lines @("$shaC`trefs/tags/v1.0.0-b50", "$shaC`trefs/tags/V1.0.0-B5") -TagName 'v1.0.0-b5')) 'pick: no line for the tag -> $null (a near-name or other case never answers)' +Assert-Case ($null -eq (Select-TagCommitFromLsRemote -Lines @() -TagName 'v1.0.0-b5')) 'pick: empty input -> $null' + # --- Body-writer machine-key invariants: BOTH artifact eras ------------------ # The writer's era switch is data-driven (what the sha map holds), so both eras # get fixtures: the zip era is what publish emits from WP-2 commit 3 onward; the diff --git a/.github/workflows/repo-gates.yml b/.github/workflows/repo-gates.yml index ba083dbcc..5084d145e 100644 --- a/.github/workflows/repo-gates.yml +++ b/.github/workflows/repo-gates.yml @@ -39,6 +39,7 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: fetch-depth: 0 + persist-credentials: false - name: Public-leak gate drill run: python3 .github/ci/public_leak_gate_drill.py @@ -113,6 +114,10 @@ jobs: shell: pwsh run: ./.github/ci/ledger_lint.ps1 -SkipApi + - name: Release tag grammar selftest (the tag grammar, the notes and body formats, the tag pick) + shell: pwsh + run: ./.github/ci/tag_regex_selftest.ps1 + # The SOURCE invariants, which need no compiler and so do not belong behind a # twenty-minute Windows build. They lived only inside build-core once, and while the build # lane was manual a prose sweep deleted every `// GEN:` annotation in the net layer -- From 05d9b165842b4dcea8c83a0901f083898027b474 Mon Sep 17 00:00:00 2001 From: pelmentor Date: Sat, 10 Oct 2026 16:10:49 +0600 Subject: [PATCH 113/306] [build] comments say which ref a recovery dispatch runs A recovery dispatch runs the workflow of the ref it is dispatched on, which the ritual dispatches on main; the release environment admits only main and the v* tags. The release-core header, the build-core self-check line and the ruleset comment say so, the judge notes why draft releases are not listed, and the keygen drill's opening paragraph is wrapped to the file's width. Comments, a docstring, and the evidence string one build step prints. Evidence: no logic changed; the gates pass unchanged. Co-Authored-By: Claude Sonnet 5.5 --- .github/ci/judge.ps1 | 2 ++ .github/ci/release_sign_keygen_drill.py | 10 +++++----- .github/workflows/build-core.yml | 2 +- .github/workflows/release-core.yml | 19 ++++++++++--------- 4 files changed, 18 insertions(+), 15 deletions(-) diff --git a/.github/ci/judge.ps1 b/.github/ci/judge.ps1 index 293073228..82709a7a4 100644 --- a/.github/ci/judge.ps1 +++ b/.github/ci/judge.ps1 @@ -134,6 +134,8 @@ if ($tag) { } } else { Add-Check 'FOREIGN_TAG' 'FAIL' 'unevaluable (tag format failed)' } +# The judge's token is read-only, so draft releases are not listed; both checks below hold over +# live releases, which every judge sees. # 10. FOREIGN_RELEASE + COMPLETION (tag-association, R18). $completion = 'NONE' if ($SkipApi) { diff --git a/.github/ci/release_sign_keygen_drill.py b/.github/ci/release_sign_keygen_drill.py index 681aad0b0..cee70e382 100644 --- a/.github/ci/release_sign_keygen_drill.py +++ b/.github/ci/release_sign_keygen_drill.py @@ -3,11 +3,11 @@ Standard library only. A fake runner answers canned `gh` output and records every call's argv and keyword arguments, so no process starts and the real `gh` is never run; each run happens inside a -temporary working directory with stdout and stderr captured. The fake answers only the -commands keygen may run (its reads, then the secret), with the output a real `gh` prints (so an echo of it is seen), and acts -on the keyword arguments the way `subprocess.run` does. Three mutants are always run and the same -assertion helper as the green arms must flag each of them; one starts a process, which the guard -below must refuse. +temporary working directory with stdout and stderr captured. The fake answers only the commands +keygen may run (its reads, then the secret), with the output a real `gh` prints (so an echo of it +is seen), and acts on the keyword arguments the way `subprocess.run` does. Three mutants are +always run and the same assertion helper as the green arms must flag each of them; one starts a +process, which the guard below must refuse. The drill names a repository that does not exist and, for its whole run, refuses the process routes it can see with an AssertionError, so a bypass of the injected runner through one of them diff --git a/.github/workflows/build-core.yml b/.github/workflows/build-core.yml index bb47546eb..6cea97626 100644 --- a/.github/workflows/build-core.yml +++ b/.github/workflows/build-core.yml @@ -67,7 +67,7 @@ jobs: $head = (git rev-parse HEAD).Trim() Write-Host "HEAD = $head" Write-Host "source-sha = $env:SOURCE_SHA" - Write-Host "github.sha = $env:GITHUB_SHA (evidence only -- on a recovery dispatch this is main's HEAD while the tree is the tag's)" + Write-Host "github.sha = $env:GITHUB_SHA (evidence only -- on a recovery dispatch this is the dispatched ref's head, main by the ritual, while the tree is the tag's)" if ($head -ne $env:SOURCE_SHA) { throw "checkout mismatch: HEAD $head != source-sha $env:SOURCE_SHA" } - name: Init build submodules (third_party only; reference/* never fetched) diff --git a/.github/workflows/release-core.yml b/.github/workflows/release-core.yml index 6fec3e1e9..4d96bfa11 100644 --- a/.github/workflows/release-core.yml +++ b/.github/workflows/release-core.yml @@ -1,11 +1,12 @@ -# release-core -- the release lane's brain, executed from main: the trampoline -# calls it @main, and a recovery dispatch runs the YAML of the ref it is -# dispatched on, which the ritual picks as main (the release environment admits -# only main and the admin-only v* tags). THIN ORCHESTRATOR (design D3): every refusal / lint / -# predicate decision lives in .github/ci/*.ps1 executed from the MAIN -# checkout (checkout#2) -- the self-check below guards the JUDGE, not just the -# evidence. The run logs workflow_ref/workflow_sha as evidence (not a gate -- -# a gate cannot guard itself; residual D7(a)). +# release-core -- the release lane's brain, executed from main: the +# trampoline calls it @main. A recovery dispatch runs the YAML of the ref it +# is dispatched on, which the ritual dispatches on main; the release +# environment admits only main and the v* tags, both admin-only. +# THIN ORCHESTRATOR (design D3): every refusal / lint / predicate decision +# lives in .github/ci/*.ps1 executed from the MAIN checkout (checkout#2) -- +# the self-check below guards the JUDGE, not just the evidence. The run logs +# workflow_ref/workflow_sha as evidence (not a gate -- a gate cannot guard +# itself; residual D7(a)). # No ${{ }} expression is pasted into a run: script (workflow_expr_gate.py): # every value a script reads comes through env:, and the tag name is held to # the release grammar before any command sees it. @@ -34,7 +35,7 @@ concurrency: cancel-in-progress: false # Read-only for every job but publish, the one that writes the Release API (D7). -# The v* tag ruleset lets only an admin create, move or delete a release tag. +# The rulesets let only an admin create, move or delete a v* tag or push to main. permissions: contents: read From 0299fb0f33abf2a188d0f85afc63b8a727d03bc9 Mon Sep 17 00:00:00 2001 From: pelmentor Date: Sat, 10 Oct 2026 16:28:55 +0600 Subject: [PATCH 114/306] [build] the keygen drill catches a ruleset of the wrong target A keygen that ignored a ruleset's target, letting branch rules count for v* tags or tag rules for main, passed all 73 checks. Five arms join: the v* rules on a branch ruleset, main's rules on a tag ruleset and a string id (all refused), a failed ruleset list read, and an empty bypass list (green: nobody may move the ref). publish holds TagSha to \z like the tag grammar; the tag selftest's header says the repo gates run it; release.md says "rulesets" without a count. Evidence: 78 checks pass; with the target comparison dropped for either kind, the drill fails that kind's arm. Co-Authored-By: Claude Opus 5.5 --- .github/ci/publish.ps1 | 2 +- .github/ci/release_sign_keygen_drill.py | 22 +++++++++++++++++++--- .github/ci/tag_regex_selftest.ps1 | 3 ++- docs/release.md | 4 ++-- 4 files changed, 24 insertions(+), 7 deletions(-) diff --git a/.github/ci/publish.ps1 b/.github/ci/publish.ps1 index 335372043..3568c7e63 100644 --- a/.github/ci/publish.ps1 +++ b/.github/ci/publish.ps1 @@ -22,7 +22,7 @@ $ErrorActionPreference = 'Stop' $tag = ConvertFrom-ReleaseTag $TagName if (-not $tag) { throw "tag '$TagName' fails the grammar (the judge should have refused)" } -if ($TagSha -cnotmatch '^[0-9a-f]{40}$') { throw "TagSha '$TagSha' is not a full lowercase commit sha" } +if ($TagSha -cnotmatch '^[0-9a-f]{40}\z') { throw "TagSha '$TagSha' is not a full lowercase commit sha" } # The release hangs on the tag NAME, its assets on TagSha. The tag is re-read from # the repository before the draft and again right before the flip makes the release public: diff --git a/.github/ci/release_sign_keygen_drill.py b/.github/ci/release_sign_keygen_drill.py index cee70e382..898aca521 100644 --- a/.github/ci/release_sign_keygen_drill.py +++ b/.github/ci/release_sign_keygen_drill.py @@ -111,8 +111,9 @@ def canned_policies(*extra): ADMIN_BYPASS = {"actor_id": 5, "actor_type": "RepositoryRole", "bypass_mode": "always"} WRITE_BYPASS = {"actor_id": 4, "actor_type": "RepositoryRole", "bypass_mode": "always"} -# id -> (target, name, ref pattern, rule types, bypass actors): the live repository's rulesets -# 10 to 12 in its order, and a second v* ruleset (13) for the arm that reads two. +# id -> (target, name, ref pattern, rule types, bypass actors): the live repository's three +# rulesets, renumbered 10 to 12 in its list order, and a second v* ruleset (13) for the arm that +# reads two. RULESET_SHAPES = { BRANCH_RULESET_ID: ("branch", "main-no-force-no-delete", "refs/heads/main", ["deletion", "non_fast_forward"], []), @@ -391,8 +392,13 @@ def drill_keygen_green(tmp): reads=READS + [ruleset_argv(13)]) check("keygen green with two tag rulesets, the first listed flawed and the second holding" + (": " + ", ".join(flaws) if flaws else ""), not flaws) + # No bypass at all holds a requirement too: nobody, admins included, may move the ref. + no_bypass = FakeGh(details=canned_details({TAG_RULESET_ID: {"bypass_actors": []}})) + flaws = keygen_flaws(rs.cmd_keygen, no_bypass, table, 2, seeds) + check("keygen green with an empty bypass list on the tag ruleset" + + (": " + ", ".join(flaws) if flaws else ""), not flaws) check("keygen green: every run's seed is its own and none is the public selftest seed", - len(seeds) == 11 and len(set(seeds)) == len(seeds) and rs.TEST_SEED_HEX not in seeds) + len(seeds) == 12 and len(set(seeds)) == len(seeds) and rs.TEST_SEED_HEX not in seeds) def drill_keygen_mutants(tmp): @@ -463,9 +469,17 @@ def drill_keygen_refuses(tmp): creation), "a boolean id on the tag summary": (canned_rulesets(edit={TAG_RULESET_ID: {"id": True}}), None, creation), + "a string id on the tag summary": (canned_rulesets(edit={TAG_RULESET_ID: {"id": "11"}}), + None, creation), + # The v* rules on a ruleset whose target is a branch hold no tag: the target is checked. + "the v* rules on a branch ruleset": (canned_rulesets( + edit={TAG_RULESET_ID: {"target": "branch"}}), {TAG_RULESET_ID: {"target": "branch"}}, + creation), "main pushes open": (canned_rulesets(drop=(MAIN_RULESET_ID,)), None, main_update), "main's ruleset lets a write role bypass": (None, {MAIN_RULESET_ID: { "bypass_actors": [ADMIN_BYPASS, WRITE_BYPASS]}}, main_update), + "main's rules on a tag ruleset": (canned_rulesets(edit={MAIN_RULESET_ID: {"target": "tag"}}), + None, main_update), } unruled_text = ("release_sign: the refs the release environment admits are not held by " "rulesets as docs/release.md sets it (%s %s)") @@ -476,6 +490,8 @@ def drill_keygen_refuses(tmp): "release_sign: gh is not installed or cannot run"), "a non-JSON answer": (FakeGh(policies=(0, "", "")), "release_sign: gh api failed (the answer is not JSON)"), + "the ruleset list read failed": (FakeGh(rulesets=(1, "", "gh: Not Found (HTTP 404)")), + "release_sign: gh api failed: gh: Not Found (HTTP 404)"), "a ruleset read failed": (FakeGh(details={ **canned_details(), BRANCH_RULESET_ID: (1, "", "gh: Not Found (HTTP 404)")}), "release_sign: gh api failed: gh: Not Found (HTTP 404)"), diff --git a/.github/ci/tag_regex_selftest.ps1 b/.github/ci/tag_regex_selftest.ps1 index c44d853f9..a35732a75 100644 --- a/.github/ci/tag_regex_selftest.ps1 +++ b/.github/ci/tag_regex_selftest.ps1 @@ -2,7 +2,8 @@ # 2026-07-26 release-body/notes/install gates): MUST-MATCH rows with expected # parse fields + MUST-REFUSE near-twins, for the tag grammar AND the notes # format lint AND the install-doc staleness patterns AND the body writer's -# machine-key invariants. Runs in every release judge pass and locally. +# machine-key invariants, and the pick of a tag's commit from ls-remote lines. +# Runs in every release judge pass, in the repo gates, and locally. # Exit 0 = all rows hold; exit 1 = a grammar/gate drifted. Set-StrictMode -Version Latest diff --git a/docs/release.md b/docs/release.md index cb24f5221..bc66b52a6 100644 --- a/docs/release.md +++ b/docs/release.md @@ -14,9 +14,9 @@ the checksum). The zip also carries `mod/dlls/main.dll.sig`, the release signature, made in the publish job with the `release` environment's key. The `release` environment holds the release key. It has the maintainer as its required reviewer, with self-review allowed and administrators unable to -bypass it, and it deploys only to tags `v*` and the branch `main`. Two rulesets hold those refs: +bypass it, and it deploys only to tags `v*` and the branch `main`. Rulesets hold those refs: only an administrator may create, move or delete a `v*` tag or push to `main`, since either -reaches the key. `release_sign.py keygen` refuses to set a key unless the environment and both +reaches the key. `release_sign.py keygen` refuses to set a key unless the environment and those rulesets stand as described here. Build numbers are minted in one place, the append-only, human-written ledger From cdfaa929becc87ad725643c97d49ea6a496cc829 Mon Sep 17 00:00:00 2001 From: pelmentor Date: Sat, 10 Oct 2026 15:51:28 +0600 Subject: [PATCH 115/306] [server] the lobby tests move into lobby/tests.rs lobby.rs was 786 lines, a test short of the 800-line cap, and the next change adds one. Its test module moves whole into the file Rust resolves for `mod tests;`, unchanged but for the indent. Evidence: cargo test passes the same 46 tests before and after. Co-Authored-By: Claude Opus 5.5 --- server/src/lobby.rs | 98 +-------------------------------------- server/src/lobby/tests.rs | 97 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 98 insertions(+), 97 deletions(-) create mode 100644 server/src/lobby/tests.rs diff --git a/server/src/lobby.rs b/server/src/lobby.rs index 1100b028a..bfb111ade 100644 --- a/server/src/lobby.rs +++ b/server/src/lobby.rs @@ -687,100 +687,4 @@ pub async fn sweeper() { } #[cfg(test)] -mod tests { - use super::{build_claim_from_body, host_listed, LobbyLinks}; - use crate::common::{build_claim_shape_ok, identity_shape_ok}; - - #[test] - fn a_lobby_row_names_its_players_links_in_one_word() { - let l = |relayed, direct, lan| LobbyLinks { relayed, direct, lan }; - assert_eq!(l(0, 0, 0).word("p2p"), ""); // nobody measured yet - assert_eq!(l(0, 0, 2).word("p2p"), "lan"); - assert_eq!(l(0, 1, 2).word("p2p"), "direct"); - assert_eq!(l(1, 1, 0).word("p2p"), "relay"); // any relayed player names the relay - assert_eq!(l(0, 0, 0).word("direct"), "direct"); // a DIRECT lobby, before anyone joins - assert_eq!(l(0, 0, 1).word("direct"), "direct"); - } - - #[test] - fn a_beats_links_are_clamped_to_the_lobby() { - let body = serde_json::json!({"links": {"relayed": 9, "direct": -2, "lan": "1"}}); - assert_eq!(LobbyLinks::from_beat(&body, 4), Some(LobbyLinks { relayed: 4, direct: 0, lan: 1 })); - assert_eq!(LobbyLinks::from_beat(&serde_json::json!({}), 4), None); - } - - #[test] - fn identity_shape_accepts_a_real_rendered_key() { - let k = format!("gen:{}", "ab01cd23".repeat(8)); // 64 hex chars - assert_eq!(k.len(), 68); - assert!(identity_shape_ok(&k)); - } - - #[test] - fn identity_shape_refuses_everything_that_would_dial_nobody() { - let hex64 = "ab01cd23".repeat(8); - // The NEGATIVES are the point: each of these would list a lobby that no - // joiner can reach, which is indistinguishable from "P2P is broken". - assert!(!identity_shape_ok("")); - assert!(!identity_shape_ok("h0123456789abcdef")); // the legacy mint - assert!(!identity_shape_ok(&hex64)); // no prefix - assert!(!identity_shape_ok(&format!("str:{hex64}"))); // wrong type - assert!(!identity_shape_ok(&format!("gen:{}", &hex64[..62]))); // short - assert!(!identity_shape_ok(&format!("gen:{hex64}0"))); // odd length - assert!(!identity_shape_ok(&format!("gen:{}", hex64.to_uppercase()))); // case - assert!(!identity_shape_ok(&format!("gen:{} x", &hex64[..62]))); // spaced - } - - #[test] - fn build_claim_shape_accepts_the_three_forms_a_host_sends() { - let sha = "ab01cd23".repeat(8); - let sig = "ef45ab67".repeat(16); - assert!(build_claim_shape_ok(&sha, 0, "")); // unsigned - assert!(build_claim_shape_ok(&sha, 1, &sig)); // a release key - assert!(build_claim_shape_ok(&sha, 255, &sig)); // the test key - } - - #[test] - fn build_claim_shape_refuses_what_cannot_be_a_claim() { - let sha = "ab01cd23".repeat(8); - let sig = "ef45ab67".repeat(16); - assert!(!build_claim_shape_ok(&sha, 256, &sig)); - assert!(!build_claim_shape_ok(&sha, -1, &sig)); - assert!(!build_claim_shape_ok(&sha, 0, &sig)); // unsigned with a signature - assert!(!build_claim_shape_ok(&sha, 1, "")); // signed without one - assert!(!build_claim_shape_ok(&sha, 1, &sig[..127])); // short signature - assert!(!build_claim_shape_ok(&sha.to_uppercase(), 0, "")); // case - assert!(!build_claim_shape_ok(&sha[..63], 0, "")); // short hash - } - - #[test] - fn a_host_body_keeps_a_well_formed_claim() { - let sha = "ab01cd23".repeat(8); - let sig = "ef45ab67".repeat(16); - let signed = serde_json::json!({"build_sha": sha, "build_key": 7, "build_sig": sig}); - assert_eq!(build_claim_from_body(&signed), (sha.clone(), 7, sig.clone())); - let unsigned = serde_json::json!({"build_sha": sha, "build_key": 0, "build_sig": ""}); - assert_eq!(build_claim_from_body(&unsigned), (sha.clone(), 0, String::new())); - let sha_only = serde_json::json!({"build_sha": sha}); - assert_eq!(build_claim_from_body(&sha_only), (sha, 0, String::new())); - } - - #[test] - fn a_host_body_drops_a_malformed_claim_and_a_missing_one_is_none() { - let sha = "ab01cd23".repeat(8); - let sig = "ef45ab67".repeat(16); - let none = (String::new(), 0u8, String::new()); - let drops = |b: serde_json::Value| assert_eq!(build_claim_from_body(&b), none); - drops(serde_json::json!({"build_sha": sha, "build_key": 0, "build_sig": sig})); - drops(serde_json::json!({"build_sha": &sha[..63], "build_key": 0, "build_sig": ""})); - drops(serde_json::json!({"build_sha": sha.to_uppercase(), "build_key": 0, "build_sig": ""})); - drops(serde_json::json!({"name": "x"})); - } - - #[test] - fn a_host_body_lists_unless_it_says_not() { - assert!(!host_listed(&serde_json::json!({"listed": false}))); - assert!(host_listed(&serde_json::json!({"listed": true}))); - assert!(host_listed(&serde_json::json!({}))); - } -} +mod tests; diff --git a/server/src/lobby/tests.rs b/server/src/lobby/tests.rs new file mode 100644 index 000000000..6957fcbd7 --- /dev/null +++ b/server/src/lobby/tests.rs @@ -0,0 +1,97 @@ +//! The lobby domain's unit tests. + +use super::{build_claim_from_body, host_listed, LobbyLinks}; +use crate::common::{build_claim_shape_ok, identity_shape_ok}; + +#[test] +fn a_lobby_row_names_its_players_links_in_one_word() { + let l = |relayed, direct, lan| LobbyLinks { relayed, direct, lan }; + assert_eq!(l(0, 0, 0).word("p2p"), ""); // nobody measured yet + assert_eq!(l(0, 0, 2).word("p2p"), "lan"); + assert_eq!(l(0, 1, 2).word("p2p"), "direct"); + assert_eq!(l(1, 1, 0).word("p2p"), "relay"); // any relayed player names the relay + assert_eq!(l(0, 0, 0).word("direct"), "direct"); // a DIRECT lobby, before anyone joins + assert_eq!(l(0, 0, 1).word("direct"), "direct"); +} + +#[test] +fn a_beats_links_are_clamped_to_the_lobby() { + let body = serde_json::json!({"links": {"relayed": 9, "direct": -2, "lan": "1"}}); + assert_eq!(LobbyLinks::from_beat(&body, 4), Some(LobbyLinks { relayed: 4, direct: 0, lan: 1 })); + assert_eq!(LobbyLinks::from_beat(&serde_json::json!({}), 4), None); +} + +#[test] +fn identity_shape_accepts_a_real_rendered_key() { + let k = format!("gen:{}", "ab01cd23".repeat(8)); // 64 hex chars + assert_eq!(k.len(), 68); + assert!(identity_shape_ok(&k)); +} + +#[test] +fn identity_shape_refuses_everything_that_would_dial_nobody() { + let hex64 = "ab01cd23".repeat(8); + // The NEGATIVES are the point: each of these would list a lobby that no + // joiner can reach, which is indistinguishable from "P2P is broken". + assert!(!identity_shape_ok("")); + assert!(!identity_shape_ok("h0123456789abcdef")); // the legacy mint + assert!(!identity_shape_ok(&hex64)); // no prefix + assert!(!identity_shape_ok(&format!("str:{hex64}"))); // wrong type + assert!(!identity_shape_ok(&format!("gen:{}", &hex64[..62]))); // short + assert!(!identity_shape_ok(&format!("gen:{hex64}0"))); // odd length + assert!(!identity_shape_ok(&format!("gen:{}", hex64.to_uppercase()))); // case + assert!(!identity_shape_ok(&format!("gen:{} x", &hex64[..62]))); // spaced +} + +#[test] +fn build_claim_shape_accepts_the_three_forms_a_host_sends() { + let sha = "ab01cd23".repeat(8); + let sig = "ef45ab67".repeat(16); + assert!(build_claim_shape_ok(&sha, 0, "")); // unsigned + assert!(build_claim_shape_ok(&sha, 1, &sig)); // a release key + assert!(build_claim_shape_ok(&sha, 255, &sig)); // the test key +} + +#[test] +fn build_claim_shape_refuses_what_cannot_be_a_claim() { + let sha = "ab01cd23".repeat(8); + let sig = "ef45ab67".repeat(16); + assert!(!build_claim_shape_ok(&sha, 256, &sig)); + assert!(!build_claim_shape_ok(&sha, -1, &sig)); + assert!(!build_claim_shape_ok(&sha, 0, &sig)); // unsigned with a signature + assert!(!build_claim_shape_ok(&sha, 1, "")); // signed without one + assert!(!build_claim_shape_ok(&sha, 1, &sig[..127])); // short signature + assert!(!build_claim_shape_ok(&sha.to_uppercase(), 0, "")); // case + assert!(!build_claim_shape_ok(&sha[..63], 0, "")); // short hash +} + +#[test] +fn a_host_body_keeps_a_well_formed_claim() { + let sha = "ab01cd23".repeat(8); + let sig = "ef45ab67".repeat(16); + let signed = serde_json::json!({"build_sha": sha, "build_key": 7, "build_sig": sig}); + assert_eq!(build_claim_from_body(&signed), (sha.clone(), 7, sig.clone())); + let unsigned = serde_json::json!({"build_sha": sha, "build_key": 0, "build_sig": ""}); + assert_eq!(build_claim_from_body(&unsigned), (sha.clone(), 0, String::new())); + let sha_only = serde_json::json!({"build_sha": sha}); + assert_eq!(build_claim_from_body(&sha_only), (sha, 0, String::new())); +} + +#[test] +fn a_host_body_drops_a_malformed_claim_and_a_missing_one_is_none() { + let sha = "ab01cd23".repeat(8); + let sig = "ef45ab67".repeat(16); + let none = (String::new(), 0u8, String::new()); + let drops = |b: serde_json::Value| assert_eq!(build_claim_from_body(&b), none); + drops(serde_json::json!({"build_sha": sha, "build_key": 0, "build_sig": sig})); + drops(serde_json::json!({"build_sha": &sha[..63], "build_key": 0, "build_sig": ""})); + drops(serde_json::json!({"build_sha": sha.to_uppercase(), "build_key": 0, "build_sig": ""})); + drops(serde_json::json!({"name": "x"})); +} + +#[test] +fn a_host_body_lists_unless_it_says_not() { + assert!(!host_listed(&serde_json::json!({"listed": false}))); + assert!(host_listed(&serde_json::json!({"listed": true}))); + assert!(host_listed(&serde_json::json!({}))); +} From 69af36e589b4b3e5d459489bb51e1b370de274bd Mon Sep 17 00:00:00 2001 From: pelmentor Date: Sat, 10 Oct 2026 15:51:47 +0600 Subject: [PATCH 116/306] [server] a refused lobby create no longer evicts a live lobby At the global cap, /v1/host makes room for a new lobby only after the version pair, the host identity and the direct port have all been checked: the eviction runs just before the insert, so a create that is refused leaves every listed lobby in place. Evidence: a new test fills the map and posts a create that is refused with a 400; every lobby is still listed. Co-Authored-By: Claude Opus 5.5 --- server/src/lobby.rs | 4 +++- server/src/lobby/tests.rs | 19 ++++++++++++++++++- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/server/src/lobby.rs b/server/src/lobby.rs index bfb111ade..15cd05269 100644 --- a/server/src/lobby.rs +++ b/server/src/lobby.rs @@ -350,7 +350,6 @@ pub fn h_host(state: &mut MasterState, ip: &str, body: &Value) -> (u16, Value) { if lobbies_per_ip(state, ip) >= MAX_LOBBIES_PER_IP { return (429, json!({"error": "too many lobbies for this address"})); } - evict_if_full(state); let mut lo = Lobby::new(ip); let name = clamp_field(body, "name", MAX_NAME); @@ -412,6 +411,9 @@ pub fn h_host(state: &mut MasterState, ip: &str, body: &Value) -> (u16, Value) { let conn = lo.conn.clone(); let is_direct = conn == "direct"; + // Room is made only for a create that passed every check above: a refused one must cost + // nobody else their lobby. + evict_if_full(state); state.lobby_by_public.insert(lobby_id.clone(), session_id.clone()); state.lobbies.insert(session_id.clone(), lo); log(&format!( diff --git a/server/src/lobby/tests.rs b/server/src/lobby/tests.rs index 6957fcbd7..f50ae1d3a 100644 --- a/server/src/lobby/tests.rs +++ b/server/src/lobby/tests.rs @@ -1,6 +1,6 @@ //! The lobby domain's unit tests. -use super::{build_claim_from_body, host_listed, LobbyLinks}; +use super::{build_claim_from_body, h_host, host_listed, Lobby, LobbyLinks, MasterState, MAX_LOBBIES_GLOBAL}; use crate::common::{build_claim_shape_ok, identity_shape_ok}; #[test] @@ -95,3 +95,20 @@ fn a_host_body_lists_unless_it_says_not() { assert!(host_listed(&serde_json::json!({"listed": true}))); assert!(host_listed(&serde_json::json!({}))); } + +#[test] +fn a_refused_create_at_the_global_cap_evicts_nothing() { + // A full map: every lobby from its own address, so no per-address cap is near. + let mut state = MasterState::new(); + for i in 0..MAX_LOBBIES_GLOBAL { + let lo = Lobby::new(&format!("10.{}.{}.1", i / 250, i % 250)); + state.lobby_by_public.insert(lo.lobby_id.clone(), lo.session_id.clone()); + state.lobbies.insert(lo.session_id.clone(), lo); + } + let before: Vec = state.lobbies.keys().cloned().collect(); + // No identity: refused with a 400 after the version gate passes the empty pair. + let (status, _) = h_host(&mut state, "198.51.100.7", &serde_json::json!({})); + assert_eq!(status, 400); + assert_eq!(state.lobbies.len(), MAX_LOBBIES_GLOBAL, "a refused create evicted a live lobby"); + assert!(before.iter().all(|sid| state.lobbies.contains_key(sid))); +} From b4db903086efffaf36e4709dd7902a807b22bb67 Mon Sep 17 00:00:00 2001 From: pelmentor Date: Sat, 10 Oct 2026 15:53:03 +0600 Subject: [PATCH 117/306] [server] the relay forwards a frame's own bytes, lowercase hex only A registered peer's line becomes its relayed frame byte for byte, through relay_frame, so a frame is never longer than its line plus the sender's name. Only a payload of lowercase hex of whole bytes ended by a newline is relayed, which is what the client sends (and an empty one for its self-echo); anything else is dropped. Evidence: a unit test covers both client shapes and nine refused ones. Co-Authored-By: Claude Opus 5.5 --- server/src/bin/signaling.rs | 62 ++++++++++++++++++++++++++++--------- 1 file changed, 47 insertions(+), 15 deletions(-) diff --git a/server/src/bin/signaling.rs b/server/src/bin/signaling.rs index 10e759ea9..68f0fc94b 100644 --- a/server/src/bin/signaling.rs +++ b/server/src/bin/signaling.rs @@ -93,10 +93,10 @@ const KEEPALIVE_INTERVAL: Duration = Duration::from_secs(5); const MAX_IDENTITY: usize = 80; // Bounded relay backlog per destination. Sizing (security audit 2026-07-16, S-1): the // prior 1024-deep queue of up-to-64 KiB items let ONE token-holder pin ~64 MiB per -// stalled destination. Signaling payloads (SDP/ICE trickle) are a few KB, so the -// relayed frame is capped at MAX_RELAY_PAYLOAD -- the longest line a registered peer -// may send at all -- and the queue at RELAY_QUEUE frames AND MAX_QUEUED_PER_DEST -// bytes. The byte cap is what bounds the whole relay: frames alone (64 x 8 KiB) +// stalled destination. Signaling payloads (SDP/ICE trickle) are a few KB, so a +// registered peer's line is capped at MAX_RELAY_PAYLOAD, the frame relayed from it is +// that line's own bytes behind the sender's name (`relay_frame`), and the queue holds +// RELAY_QUEUE frames AND MAX_QUEUED_PER_DEST bytes. The byte cap is what bounds the whole relay: frames alone (64 x 8 KiB) // times the 512 authed slots came to 256 MiB, twice the unit's MemoryMax, so a few // sources that register and stop reading could have it OOM-killed. 128 KiB per // destination is 64 MiB over every slot, and still holds 64 small ICE frames. @@ -402,17 +402,7 @@ fn relay_line(sender: &str, line: &[u8]) { if line.len() > MAX_RELAY_PAYLOAD { return; } - let text = String::from_utf8_lossy(line); - let sp = match text.find(' ') { - Some(i) if i > 0 => i, - _ => return, // no dest, or leading space -> drop (matches `sp <= 0: continue`) - }; - let dest = text[..sp].trim(); - let payload = &text[sp + 1..]; // keeps the trailing '\n' - if dest.is_empty() { - return; - } - let msg = format!("{sender} {payload}").into_bytes(); + let Some((dest, msg)) = relay_frame(sender, line) else { return }; let cl = CLIENTS.lock().unwrap_or_else(|e| e.into_inner()); if let Some(route) = cl.get(dest) { // Non-blocking and best-effort: drop when the destination's byte budget is @@ -425,6 +415,26 @@ fn relay_line(sender: &str, line: &[u8]) { } } +/// The frame one line becomes: ` \n` in, ` \n` out, the payload's bytes +/// copied as they arrived. The payload must be lowercase hex of whole bytes -- the client's own +/// encoding (coop/net/signaling_client.cpp, `kHexDigit`), empty for the self-echo -- ended by the +/// newline; anything else is dropped. So a frame is never longer than its line plus the sender's +/// name, where a lossy re-decode of invalid UTF-8 once tripled it. +fn relay_frame<'a>(sender: &str, line: &'a [u8]) -> Option<(&'a str, Vec)> { + let sp = line.iter().position(|b| *b == b' ').filter(|i| *i > 0)?; + let dest = std::str::from_utf8(&line[..sp]).ok()?; + let payload = &line[sp + 1..]; + let hex = payload.strip_suffix(b"\n")?; + if hex.len() % 2 != 0 || !hex.iter().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')) { + return None; + } + let mut frame = Vec::with_capacity(sender.len() + 1 + payload.len()); + frame.extend_from_slice(sender.as_bytes()); + frame.push(b' '); + frame.extend_from_slice(payload); + Some((dest, frame)) +} + /// Enable TCP keepalive with an explicit idle/interval, so a dead authed peer is reaped in about a /// minute instead of at the OS default (see KEEPALIVE_IDLE for what the default costs). Portable /// via socket2's SockRef; failure is non-fatal, and falls back to the bare flag so the reap still @@ -659,6 +669,28 @@ mod tests { assert_eq!(budget.load(Ordering::Relaxed), 0, "dropped frames kept their bytes charged"); } + #[test] + fn a_relayed_frame_is_the_line_s_own_bytes_and_only_hex_is_relayed() { + use super::relay_frame; + let me = "gen:aa"; + assert_eq!(relay_frame(me, b"gen:bb 0a1bff\n"), Some(("gen:bb", b"gen:aa 0a1bff\n".to_vec()))); + // The self-echo: an empty payload addressed to its sender. + assert_eq!(relay_frame(me, b"gen:aa \n"), Some(("gen:aa", b"gen:aa \n".to_vec()))); + for bad in [ + &b"gen:bb \xff\xfe\n"[..], // invalid UTF-8 the old re-decode expanded threefold + b"gen:bb 0A1B\n", // upper case: the client never sends it + b"gen:bb 0a1\n", // half a byte + b"gen:bb 0a 1b\n", // a second space + b"gen:bb 0a1b", // no newline + b"gen:bb 0a1b\r\n", // a carriage return + b" 0a1b\n", // no destination + b"gen:bb\n", // no payload separator + b"\xff\xfe 0a\n", // a destination that is not text + ] { + assert_eq!(relay_frame(me, bad), None, "relayed {:?}", String::from_utf8_lossy(bad)); + } + } + #[tokio::test] async fn a_line_split_across_reads_is_found_and_an_overlong_one_refused() { use super::{LineErr, LineReader}; From 2be843bdc4f79550f23aba4481fca1daae095041 Mon Sep 17 00:00:00 2001 From: pelmentor Date: Sat, 10 Oct 2026 15:53:42 +0600 Subject: [PATCH 118/306] [server] rustls 0.23.45 on the TLS listeners The master and relay terminate TLS with rustls; the lock moves from 0.23.42 to 0.23.45, the release RUSTSEC-2026-0285 lists as patched, and cargo update -p rustls also moves rustls-webpki 0.103.13 -> 0.103.15 within its semver range. Cargo.toml is unchanged. Evidence: cargo test passes the 48 tests on the new lock. Co-Authored-By: Claude Opus 5.5 --- server/Cargo.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/server/Cargo.lock b/server/Cargo.lock index 7442147ea..d64bab707 100644 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -213,9 +213,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.42" +version = "0.23.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" dependencies = [ "log", "once_cell", @@ -246,9 +246,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.13" +version = "0.103.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" dependencies = [ "ring", "rustls-pki-types", From 6199e5e6976e43cc9f7f06338a95ec8bed5b014d Mon Sep 17 00:00:00 2001 From: pelmentor Date: Sat, 10 Oct 2026 16:07:08 +0600 Subject: [PATCH 119/306] [server] a master request keeps three headers and a fixed head buffer parse_head keeps only what the master reads (content-length, x-real-ip, x-forwarded-for, the last of each) and stores nothing for any other line, so a head costs its own bytes. A head that is not UTF-8 is refused with a 400, and read_head fills one buffer of max + 4 bytes and never reads past it. Evidence: new tests parse 4,000 header lines keeping nothing and stop a 40,000-byte head at the 1,028-byte cap. Co-Authored-By: Claude Opus 5.5 --- server/src/bin/master.rs | 42 ++++++---------- server/src/http_transport.rs | 95 +++++++++++++++++++++++++++++++++--- server/src/lobby.rs | 6 +-- 3 files changed, 105 insertions(+), 38 deletions(-) diff --git a/server/src/bin/master.rs b/server/src/bin/master.rs index 74537d8f3..c7cfa119a 100644 --- a/server/src/bin/master.rs +++ b/server/src/bin/master.rs @@ -14,7 +14,8 @@ use coop_server::admission::Pool; use coop_server::common::{clamp_str, env_int, env_str, log}; use coop_server::http_transport::{ - json_bytes, read_head, write_response, HeadErr, CONNS, HTTP_TIMEOUT, MAX_BODY, MAX_HEADER, + json_bytes, parse_head, read_head, write_response, HeadErr, HeadReject, CONNS, HTTP_TIMEOUT, MAX_BODY, + MAX_HEADER, }; use coop_server::lobby::{ dispatch_post, filter_lobbies, lobbies_snapshot, lock_state, resolve_client_ip, sweeper, @@ -28,7 +29,6 @@ use std::sync::LazyLock; use std::time::Duration; use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite}; use tokio::time::{timeout_at, Instant}; -use std::collections::HashMap; use tokio::net::TcpListener; /// One request. The connection was admitted at accept, and `deadline` -- set there too -- bounds @@ -44,39 +44,25 @@ async fn handle(mut stream: S, peer_ip: Strin Ok(Err(HeadErr::Closed)) | Err(_) => return, // eof / timeout }; - let head_str = String::from_utf8_lossy(&head); - let mut lines = head_str.split("\r\n"); - let request_line = lines.next().unwrap_or(""); - let mut rl = request_line.splitn(3, ' '); - let (method, raw_path) = match (rl.next(), rl.next(), rl.next()) { - (Some(m), Some(p), Some(_)) => (m, p), - _ => { - write_response(&mut stream, 400, &json_bytes(&json!({"error": "bad request line"}))).await; + let req = match parse_head(&head) { + Ok(h) => h, + Err(why) => { + let error = match why { + HeadReject::NotText => "head is not text", + HeadReject::BadRequestLine => "bad request line", + }; + write_response(&mut stream, 400, &json_bytes(&json!({ "error": error }))).await; return; } }; - - let mut headers: HashMap = HashMap::new(); - for ln in lines { - if ln.is_empty() { - continue; - } - if let Some((k, v)) = ln.split_once(':') { - headers.insert(k.trim().to_lowercase(), v.trim().to_string()); - } - } - - let (path, query) = match raw_path.split_once('?') { - Some((p, q)) => (p, q), - None => (raw_path, ""), - }; - let client_ip = resolve_client_ip(&peer_ip, &headers); + let (method, path, query) = (req.method, req.path, req.query); + let client_ip = resolve_client_ip(&peer_ip, req.x_real_ip, req.x_forwarded_for); // ---- body (POST only, bounded) ---- let mut body_obj = Value::Object(serde_json::Map::new()); if method == "POST" { - let clen: i64 = headers - .get("content-length") + let clen: i64 = req + .content_length .and_then(|s| s.trim().parse::().ok()) .unwrap_or(-1); if clen < 0 || clen as usize > MAX_BODY { diff --git a/server/src/http_transport.rs b/server/src/http_transport.rs index e9cb4837e..ec1867dc3 100644 --- a/server/src/http_transport.rs +++ b/server/src/http_transport.rs @@ -66,14 +66,15 @@ pub fn find_subsequence(hay: &[u8], needle: &[u8]) -> Option { hay.windows(needle.len()).position(|w| w == needle) } -/// Read the header block up to (and consuming) the terminating CRLFCRLF, bounded by -/// `max`. Returns (head_bytes, leftover_body_bytes_already_read). Mirrors the Python -/// `readuntil(b"\r\n\r\n")` with the start_server `limit`. +/// Read the header block up to (and consuming) the terminating CRLFCRLF, which must end within +/// `max + 4` bytes. Returns (head_bytes, leftover_body_bytes_already_read). The buffer is sized +/// once and no read goes past it, so a head costs `max + 4` bytes at most, never a grown copy. pub async fn read_head( stream: &mut S, max: usize, ) -> Result<(Vec, Vec), HeadErr> { - let mut buf: Vec = Vec::with_capacity(2048); + let cap = max + 4; + let mut buf: Vec = Vec::with_capacity(cap); let mut tmp = [0u8; 4096]; // Search only what arrived since the last look, less the terminator's length so one split // across two reads is still found. Rescanning the whole buffer made a head trickled a byte @@ -85,11 +86,12 @@ pub async fn read_head( buf.truncate(i); return Ok((buf, leftover)); } - if buf.len() > max { + let room = (cap - buf.len()).min(tmp.len()); + if room == 0 { return Err(HeadErr::TooLarge); } from = buf.len().saturating_sub(3); - let n = stream.read(&mut tmp).await.map_err(|_| HeadErr::Closed)?; + let n = stream.read(&mut tmp[..room]).await.map_err(|_| HeadErr::Closed)?; if n == 0 { return Err(HeadErr::Closed); } @@ -102,11 +104,90 @@ pub enum HeadErr { Closed, } +/// The parts of a request head the master reads. Every other header line is skipped as it is +/// parsed and nothing is kept for it, so a head costs its own bytes however many lines it holds. +pub struct Head<'a> { + pub method: &'a str, + pub path: &'a str, + pub query: &'a str, + pub content_length: Option<&'a str>, + pub x_real_ip: Option<&'a str>, + pub x_forwarded_for: Option<&'a str>, +} + +pub enum HeadReject { + /// Not UTF-8: an HTTP head from a client of ours is ASCII. + NotText, + BadRequestLine, +} + +/// Parse a head `read_head` returned. A repeated header keeps its last value. +pub fn parse_head(head: &[u8]) -> Result, HeadReject> { + let text = std::str::from_utf8(head).map_err(|_| HeadReject::NotText)?; + let mut lines = text.split("\r\n"); + let mut rl = lines.next().unwrap_or("").splitn(3, ' '); + let (method, raw_path) = match (rl.next(), rl.next(), rl.next()) { + (Some(m), Some(p), Some(_)) => (m, p), + _ => return Err(HeadReject::BadRequestLine), + }; + let (path, query) = raw_path.split_once('?').unwrap_or((raw_path, "")); + let mut h = Head { method, path, query, content_length: None, x_real_ip: None, x_forwarded_for: None }; + for ln in lines { + let Some((k, v)) = ln.split_once(':') else { continue }; + let k = k.trim(); + let slot = if k.eq_ignore_ascii_case("content-length") { + &mut h.content_length + } else if k.eq_ignore_ascii_case("x-real-ip") { + &mut h.x_real_ip + } else if k.eq_ignore_ascii_case("x-forwarded-for") { + &mut h.x_forwarded_for + } else { + continue; + }; + *slot = Some(v.trim()); + } + Ok(h) +} + #[cfg(test)] mod tests { - use super::{read_head, HeadErr}; + use super::{parse_head, read_head, HeadErr, HeadReject}; use tokio::io::AsyncWriteExt; + #[test] + fn a_head_keeps_only_the_headers_the_master_reads() { + let h = parse_head(b"POST /v1/host?x=1 HTTP/1.1\r\nContent-Length: 12\r\nX-Real-IP: 1.2.3.4\r\nA: b") + .ok() + .expect("a well-formed head was refused"); + assert_eq!((h.method, h.path, h.query), ("POST", "/v1/host", "x=1")); + assert_eq!((h.content_length, h.x_real_ip, h.x_forwarded_for), (Some("12"), Some("1.2.3.4"), None)); + let again = parse_head(b"GET / HTTP/1.1\r\ncontent-length: 1\r\nCONTENT-LENGTH: 2").ok().unwrap(); + assert_eq!(again.content_length, Some("2"), "a repeated header did not keep its last value"); + // Thousands of tiny header lines cost nothing past the head's own bytes: nothing is kept. + let mut many = b"GET / HTTP/1.1".to_vec(); + for _ in 0..4000 { + many.extend_from_slice(b"\r\na:"); + } + assert!(parse_head(&many).is_ok()); + } + + #[test] + fn a_head_that_is_not_text_or_has_no_request_line_is_refused() { + assert!(matches!(parse_head(b"GET / HTTP/1.1\r\nA: \xff\xfe"), Err(HeadReject::NotText))); + assert!(matches!(parse_head(b"GET /"), Err(HeadReject::BadRequestLine))); + } + + #[tokio::test] + async fn a_head_never_reads_past_its_cap() { + // One write far past the cap: the reader takes at most cap bytes, then refuses. + let (mut tx, mut rx) = tokio::io::duplex(1 << 16); + tx.write_all(&[b'a'; 40_000]).await.unwrap(); + assert!(matches!(read_head(&mut rx, 1024).await, Err(HeadErr::TooLarge))); + let mut left = vec![0u8; 40_000]; + let n = tokio::io::AsyncReadExt::read(&mut rx, &mut left).await.unwrap(); + assert!(n >= 40_000 - 1028, "the reader consumed {} bytes past a 1028-byte cap", 40_000 - n - 1028); + } + #[tokio::test] async fn a_terminator_split_across_reads_is_found() { let (mut tx, mut rx) = tokio::io::duplex(64); diff --git a/server/src/lobby.rs b/server/src/lobby.rs index 15cd05269..fa4b54d0c 100644 --- a/server/src/lobby.rs +++ b/server/src/lobby.rs @@ -264,15 +264,15 @@ pub fn host_listed(body: &Value) -> bool { // ---- helpers ---------------------------------------------------------------- -pub fn resolve_client_ip(peer_ip: &str, headers: &HashMap) -> String { +pub fn resolve_client_ip(peer_ip: &str, x_real_ip: Option<&str>, x_forwarded_for: Option<&str>) -> String { if TRUSTED_PROXY_PEERS.contains(&peer_ip) { - if let Some(xr) = headers.get("x-real-ip") { + if let Some(xr) = x_real_ip { let xr = xr.trim(); if !xr.is_empty() { return xr.to_string(); } } - if let Some(xff) = headers.get("x-forwarded-for") { + if let Some(xff) = x_forwarded_for { let parts: Vec<&str> = xff.split(',').map(|p| p.trim()).filter(|p| !p.is_empty()).collect(); if let Some(last) = parts.last() { return last.to_string(); // rightmost = what the trusted proxy observed From 20758e57b3bf0e96a815b58bf9cee813e70d31b7 Mon Sep 17 00:00:00 2001 From: pelmentor Date: Sat, 10 Oct 2026 16:07:08 +0600 Subject: [PATCH 120/306] [server] the relay's line reader never holds more than max + 1 bytes Each read of LineReader asks for at most the room left up to max + 1, so a connection holds at most one byte past its line cap before the line is refused, whatever arrives in one read. Evidence: the line test asserts the buffer at <= 17 bytes after a 40-byte write against a 16-byte cap. Co-Authored-By: Claude Opus 5.5 --- server/src/bin/signaling.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/server/src/bin/signaling.rs b/server/src/bin/signaling.rs index 68f0fc94b..505f253e1 100644 --- a/server/src/bin/signaling.rs +++ b/server/src/bin/signaling.rs @@ -146,11 +146,15 @@ impl LineReader { return Ok(line); } self.scanned = self.buf.len(); - if self.buf.len() > self.max { + // A read never takes more than one byte past `max`, so a slot holds at most `max + 1` + // bytes of line; a whole 4 KiB read appended before the check let it hold more. + let room = (self.max + 1).saturating_sub(self.buf.len()); + if room == 0 { return Err(LineErr::TooLong); } let mut tmp = [0u8; 4096]; - let n = self.rh.read(&mut tmp).await.map_err(|_| LineErr::Closed)?; + let room = room.min(tmp.len()); + let n = self.rh.read(&mut tmp[..room]).await.map_err(|_| LineErr::Closed)?; if n == 0 { return Err(LineErr::Closed); } @@ -705,6 +709,7 @@ mod tests { assert_eq!(lr.next_line().await.ok().unwrap(), b"xyz\n"); tx.write_all(&[b'a'; 40]).await.unwrap(); assert!(matches!(lr.next_line().await, Err(LineErr::TooLong)), "a line past the cap was buffered on"); + assert!(lr.buf.len() <= 17, "the reader held {} bytes against a cap of 16", lr.buf.len()); } #[tokio::test] From 58f3802d2c05fc4743d3318b78879b97fce45c98 Mon Sep 17 00:00:00 2001 From: pelmentor Date: Sat, 10 Oct 2026 16:12:17 +0600 Subject: [PATCH 121/306] [server] a master body is 4 KiB at most and gone before the answer MAX_BODY is 4 KiB: the host announce is the largest body a client sends. The head is dropped before the body is read, and a POST is read, parsed and answered in one expression, so its body and its parse are gone before the answer is written. TLS connections on both servers keep at most 16 KiB of records unsent. tests/conn_cost.rs counts, with a counting allocator, what a body's parse and a TLS connection hold. Evidence: a new test drives handle() for a host announce (200, listed), a 413 and two 400s; conn_cost.rs prints the parse costs. Co-Authored-By: Claude Opus 5.5 --- server/src/bin/master.rs | 128 +++++++++++++++++++++++----------- server/src/bin/signaling.rs | 2 +- server/src/http_transport.rs | 6 +- server/src/tls.rs | 9 +++ server/tests/conn_cost.rs | 129 +++++++++++++++++++++++++++++++++++ 5 files changed, 232 insertions(+), 42 deletions(-) create mode 100644 server/tests/conn_cost.rs diff --git a/server/src/bin/master.rs b/server/src/bin/master.rs index c7cfa119a..3d7789aa7 100644 --- a/server/src/bin/master.rs +++ b/server/src/bin/master.rs @@ -44,8 +44,14 @@ async fn handle(mut stream: S, peer_ip: Strin Ok(Err(HeadErr::Closed)) | Err(_) => return, // eof / timeout }; - let req = match parse_head(&head) { - Ok(h) => h, + let (method, path, query, content_length, client_ip) = match parse_head(&head) { + Ok(h) => ( + h.method.to_string(), + h.path.to_string(), + h.query.to_string(), + h.content_length.map(str::to_string), + resolve_client_ip(&peer_ip, h.x_real_ip, h.x_forwarded_for), + ), Err(why) => { let error = match why { HeadReject::NotText => "head is not text", @@ -55,40 +61,23 @@ async fn handle(mut stream: S, peer_ip: Strin return; } }; - let (method, path, query) = (req.method, req.path, req.query); - let client_ip = resolve_client_ip(&peer_ip, req.x_real_ip, req.x_forwarded_for); + // A connection holds its head or its body, never both. + drop(head); + let (method, path, query) = (method.as_str(), path.as_str(), query.as_str()); - // ---- body (POST only, bounded) ---- - let mut body_obj = Value::Object(serde_json::Map::new()); + // ---- POST: read, parse and answer in one expression, so the body and its parse are dropped + // before the answer's write, which can wait out the deadline on a client that stops reading. if method == "POST" { - let clen: i64 = req - .content_length - .and_then(|s| s.trim().parse::().ok()) - .unwrap_or(-1); - if clen < 0 || clen as usize > MAX_BODY { - write_response(&mut stream, 413, &json_bytes(&json!({"error": "body too large"}))).await; - return; - } - let clen = clen as usize; - let mut raw = leftover; - if raw.len() < clen { - let need = clen - raw.len(); - let mut rest = vec![0u8; need]; - match timeout_at(deadline, stream.read_exact(&mut rest)).await { - Ok(Ok(_)) => raw.extend_from_slice(&rest), - _ => return, // eof / timeout - } - } - raw.truncate(clen); - if !raw.is_empty() { - match serde_json::from_slice::(&raw) { - Ok(v) if v.is_object() => body_obj = v, - _ => { - write_response(&mut stream, 400, &json_bytes(&json!({"error": "bad json"}))).await; - return; - } - } - } + let (status, answer) = match read_post_body(&mut stream, leftover, content_length.as_deref(), deadline).await { + Err(None) => return, // eof / timeout + Err(Some((status, error))) => (status, json_bytes(&json!({ "error": error }))), + Ok(body) => match dispatch_post(path, &client_ip, &body) { + Some((status, resp)) => (status, json_bytes(&resp)), + None => (404, json_bytes(&json!({"error": "not found"}))), + }, + }; + write_response(&mut stream, status, &answer).await; + return; } // ---- route ---- @@ -143,16 +132,36 @@ async fn handle(mut stream: S, peer_ip: Strin } else if method == "GET" && path == "/healthz" { let n = lock_state().lobbies.len(); write_response(&mut stream, 200, &json_bytes(&json!({"ok": true, "lobbies": n}))).await; - } else if method == "POST" { - match dispatch_post(path, &client_ip, &body_obj) { - Some((status, resp)) => write_response(&mut stream, status, &json_bytes(&resp)).await, - None => write_response(&mut stream, 404, &json_bytes(&json!({"error": "not found"}))).await, - } } else { write_response(&mut stream, 404, &json_bytes(&json!({"error": "not found"}))).await; } } +/// A POST body, read into one buffer of its declared length and parsed as a JSON object. +/// `Err(None)`: the client went away or ran out the deadline; `Err(Some(..))`: the answer that +/// refuses it. +async fn read_post_body( + stream: &mut S, + mut raw: Vec, + content_length: Option<&str>, + deadline: Instant, +) -> Result> { + let clen = content_length.and_then(|s| s.trim().parse::().ok()).filter(|n| *n <= MAX_BODY); + let Some(clen) = clen else { return Err(Some((413, "body too large"))) }; + let have = raw.len().min(clen); + raw.resize(clen, 0); + if have < clen && !matches!(timeout_at(deadline, stream.read_exact(&mut raw[have..])).await, Ok(Ok(_))) { + return Err(None); + } + if raw.is_empty() { + return Ok(Value::Object(serde_json::Map::new())); + } + match serde_json::from_slice::(&raw) { + Ok(v) if v.is_object() => Ok(v), + _ => Err(Some((400, "bad json"))), + } +} + #[tokio::main] async fn main() { // fail fast on missing secrets (same as the Python FATAL exits) @@ -262,7 +271,7 @@ async fn serve_tls( let acceptor = acceptor.clone(); tokio::spawn(async move { let _slot = slot; - match timeout_at(deadline, acceptor.accept(stream)).await { + match timeout_at(deadline, acceptor.accept_with(stream, tls::limit_send_buffer)).await { Ok(Ok(tls_stream)) => handle(tls_stream, peer_ip, deadline).await, Ok(Err(e)) => log(&format!("tls handshake failed [{peer_ip}]: {e}")), Err(_) => log(&format!("tls handshake timed out [{peer_ip}]")), @@ -314,6 +323,45 @@ mod tests { (l, a) } + /// One request through `handle` over an in-memory pipe: the status and the body it answered. + async fn ask(request: Vec) -> (u16, String) { + use tokio::io::AsyncWriteExt; + let (mut client, server) = tokio::io::duplex(1 << 16); + let deadline = tokio::time::Instant::now() + Duration::from_secs(5); + let served = tokio::spawn(super::handle(server, "192.0.2.50".to_string(), deadline)); + client.write_all(&request).await.unwrap(); + let mut answer = Vec::new(); + client.read_to_end(&mut answer).await.unwrap(); + served.await.unwrap(); + let text = String::from_utf8_lossy(&answer).to_string(); + let status = text.get(9..12).and_then(|s| s.parse().ok()).unwrap_or(0); + (status, text.split("\r\n\r\n").nth(1).unwrap_or("").to_string()) + } + + fn post(path: &str, body: &[u8]) -> Vec { + let mut r = format!("POST {path} HTTP/1.1\r\nHost: x\r\nContent-Length: {}\r\n\r\n", body.len()).into_bytes(); + r.extend_from_slice(body); + r + } + + #[tokio::test] + async fn a_post_is_answered_and_its_limits_hold() { + let identity = format!("gen:{}", "ab01cd23".repeat(8)); + let host = serde_json::json!({"name": "t", "game": "0.9.0n", "proto": 216, "identity": identity, + "conn": "direct", "direct_port": 7777}); + let (status, body) = ask(post("/v1/host", host.to_string().as_bytes())).await; + assert_eq!(status, 200, "a direct host announce was refused: {body}"); + let lobby: serde_json::Value = serde_json::from_str(&body).unwrap(); + let id = lobby["lobbyId"].as_str().expect("no lobbyId").to_string(); + let (status, list) = ask(b"GET /v1/lobbies HTTP/1.1\r\nHost: x\r\n\r\n".to_vec()).await; + assert!(status == 200 && list.contains(&id), "the new lobby is not listed"); + + let over = vec![b' '; super::MAX_BODY + 1]; + assert_eq!(ask(post("/v1/host", &over)).await.0, 413, "a body past the cap was read"); + assert_eq!(ask(post("/v1/host", b"{nope")).await.0, 400); + assert_eq!(ask(b"GET / HTTP/1.1\r\nA: \xff\r\n\r\n".to_vec()).await.0, 400, "a non-text head was read"); + } + #[tokio::test] async fn a_client_that_never_sends_a_client_hello_is_dropped_at_the_deadline() { static POOL: LazyLock = LazyLock::new(|| Pool::new("test", 8, 8)); diff --git a/server/src/bin/signaling.rs b/server/src/bin/signaling.rs index 505f253e1..713c671b5 100644 --- a/server/src/bin/signaling.rs +++ b/server/src/bin/signaling.rs @@ -536,7 +536,7 @@ async fn serve_tls( let deadline = Instant::now() + budget; let acceptor = acceptor.clone(); tokio::spawn(async move { - match timeout_at(deadline, acceptor.accept(stream)).await { + match timeout_at(deadline, acceptor.accept_with(stream, tls::limit_send_buffer)).await { Ok(Ok(tls_stream)) => handle(tls_stream, ip, "tls", pending, deadline).await, Ok(Err(e)) => log(&format!("tls handshake failed [{ip}]: {e}")), Err(_) => log(&format!("tls handshake timed out [{ip}]")), diff --git a/server/src/http_transport.rs b/server/src/http_transport.rs index ec1867dc3..3a40ef289 100644 --- a/server/src/http_transport.rs +++ b/server/src/http_transport.rs @@ -12,7 +12,11 @@ use tokio::time::timeout; /// the body. The answer's write is bounded separately (`write_response`). pub const HTTP_TIMEOUT: Duration = Duration::from_secs(15); pub const MAX_HEADER: usize = 16 * 1024; -pub const MAX_BODY: usize = 64 * 1024; +/// The largest body a client of ours sends is the host announce: under 1.5 KiB with its name and +/// world at their 63-byte input caps, fully escaped (coop/net/lobby_announcer.cpp). 4 KiB leaves +/// room and keeps a body's parse small: a `serde_json::Value` can cost 16x its bytes +/// (tests/conn_cost.rs: a 64 KiB array of zeros parses to 1 MiB). +pub const MAX_BODY: usize = 4 * 1024; pub const MAX_CONNS: usize = 256; /// Per source address. A client holds at most a few short requests at once (a list refresh, a /// heartbeat, a join), so 16 leaves room for a household or a carrier NAT while keeping one source diff --git a/server/src/tls.rs b/server/src/tls.rs index 819958fb6..8bee50076 100644 --- a/server/src/tls.rs +++ b/server/src/tls.rs @@ -32,6 +32,15 @@ use tokio_rustls::TlsAcceptor; /// listener: a var that IS set but unreadable is a FATAL error (below), because /// silently falling back to cleartext on a box that was meant to serve TLS is /// exactly the failure this tier exists to prevent. +/// TLS records a connection may hold unsent before a write waits for the peer to read: rustls' +/// default is 64 KiB, the size of a whole lobby list, held by any client that stops reading. +pub const SEND_BUFFER_LIMIT: usize = 16 * 1024; + +/// For `TlsAcceptor::accept_with`: every accepted connection gets `SEND_BUFFER_LIMIT`. +pub fn limit_send_buffer(conn: &mut tokio_rustls::rustls::ServerConnection) { + conn.set_buffer_limit(Some(SEND_BUFFER_LIMIT)); +} + pub fn acceptor_from_env() -> Option { let cert_path = env_str("COOP_TLS_CERT", ""); let key_path = env_str("COOP_TLS_KEY", ""); diff --git a/server/tests/conn_cost.rs b/server/tests/conn_cost.rs new file mode 100644 index 000000000..2b1fc7753 --- /dev/null +++ b/server/tests/conn_cost.rs @@ -0,0 +1,129 @@ +//! What one connection can make the master or the relay hold, counted on the heap by a global +//! allocator, for the pool sizes to rest on. One test function, so nothing else allocates while a +//! phase is counted. `cargo test --test conn_cost -- --nocapture` prints the table. + +use serde_json::Value; +use std::alloc::{GlobalAlloc, Layout, System}; +use std::sync::atomic::{AtomicIsize, Ordering}; +use std::sync::Arc; +use tokio_rustls::rustls::server::{ClientHello, ResolvesServerCert}; +use tokio_rustls::rustls::sign::CertifiedKey; +use tokio_rustls::rustls::{ServerConfig, ServerConnection}; + +struct Counting; +static LIVE: AtomicIsize = AtomicIsize::new(0); + +unsafe impl GlobalAlloc for Counting { + unsafe fn alloc(&self, l: Layout) -> *mut u8 { + let p = System.alloc(l); + if !p.is_null() { + LIVE.fetch_add(l.size() as isize, Ordering::SeqCst); + } + p + } + unsafe fn dealloc(&self, p: *mut u8, l: Layout) { + System.dealloc(p, l); + LIVE.fetch_sub(l.size() as isize, Ordering::SeqCst); + } + unsafe fn realloc(&self, p: *mut u8, l: Layout, n: usize) -> *mut u8 { + let q = System.realloc(p, l, n); + if !q.is_null() { + LIVE.fetch_add(n as isize - l.size() as isize, Ordering::SeqCst); + } + q + } +} + +#[global_allocator] +static COUNTING: Counting = Counting; + +/// Heap bytes `make` leaves alive while its result is held. +fn held(make: impl FnOnce() -> T) -> (T, isize) { + let before = LIVE.load(Ordering::SeqCst); + let v = make(); + (v, LIVE.load(Ordering::SeqCst) - before) +} + +#[derive(Debug)] +struct NoCert; +impl ResolvesServerCert for NoCert { + fn resolve(&self, _: ClientHello<'_>) -> Option> { + None + } +} + +/// A body of `n` bytes in one of the shapes a hostile client may send. +fn body(shape: &str, n: usize) -> Vec { + let mut b = Vec::with_capacity(n + 16); + match shape { + "array of zeros" => { + b.extend_from_slice(b"{\"a\":[0"); + while b.len() + 3 < n { + b.extend_from_slice(b",0"); + } + b.extend_from_slice(b"]}"); + } + "many keys" => { + b.push(b'{'); + let mut i = 0u32; + while b.len() + 24 < n { + if i > 0 { + b.push(b','); + } + b.extend_from_slice(format!("\"{i:x}\":0").as_bytes()); + i += 1; + } + b.push(b'}'); + } + "empty objects" => { + b.extend_from_slice(b"{\"a\":[{}"); + while b.len() + 5 < n { + b.extend_from_slice(b",{}"); + } + b.extend_from_slice(b"]}"); + } + "one long string" => { + b.extend_from_slice(b"{\"a\":\""); + while b.len() + 3 < n { + b.push(b'x'); + } + b.extend_from_slice(b"\"}"); + } + _ => unreachable!(), + } + b +} + +#[test] +fn connection_costs() { + let kib = |n: isize| n as f64 / 1024.0; + println!("phase | heap held"); + + // The master's parsed body, for a body at the cap and at 8 KiB. + for cap in [64 * 1024usize, 8 * 1024] { + for shape in ["array of zeros", "many keys", "empty objects", "one long string"] { + let raw = body(shape, cap); + let (v, cost) = held(|| serde_json::from_slice::(&raw).expect("the fixture is JSON")); + println!("master body {cap} B, {shape}: {:.1} KiB parsed", kib(cost)); + drop(v); + } + } + + // A rustls server connection holding one partial 16 KiB handshake record. + let cfg = Arc::new(ServerConfig::builder().with_no_client_auth().with_cert_resolver(Arc::new(NoCert))); + let (conn, new_cost) = held(|| ServerConnection::new(cfg.clone()).expect("a server connection")); + println!("tls server connection, new: {:.1} KiB", kib(new_cost)); + let mut conn = conn; + let mut record = vec![0x16u8, 0x03, 0x01, 0x40, 0x00]; + record.resize(5 + 16 * 1024 - 1, 0); + let ((), buffered) = held(|| { + let mut rd = &record[..]; + while !rd.is_empty() { + if conn.read_tls(&mut rd).unwrap_or(0) == 0 { + break; + } + } + }); + println!("tls server connection, a 16 KiB record pending: +{:.1} KiB", kib(buffered)); + drop(conn); +} From b43ab8035336d9d613ff6745559091aa9d5a1a5b Mon Sep 17 00:00:00 2001 From: pelmentor Date: Sat, 10 Oct 2026 16:33:04 +0600 Subject: [PATCH 122/306] [server] both servers ignore an address that floods connects MTA's HTTP server keeps a connect history per address and ignores one that makes more than 20 connects inside 10 s for a minute. Both servers now do the same at accept, before a pool slot or a TLS handshake is spent: ConnectHistory in admission.rs, one per process across its listeners, keyed like the pools, swept each second, the ban logged once per line-second. Past 50,000 tracked addresses a newcomer is admitted untracked, so a source-diverse flood cannot lock newcomers out. Evidence: unit tests cover the ban, its end, ageing and the sweep; the relay's sig_gate drill passes 15/15 against this build. Co-Authored-By: Claude Opus 5.5 --- server/src/admission.rs | 143 +++++++++++++++++++++++++++++++++--- server/src/bin/master.rs | 8 +- server/src/bin/signaling.rs | 8 +- 3 files changed, 145 insertions(+), 14 deletions(-) diff --git a/server/src/admission.rs b/server/src/admission.rs index 7ebe515cf..c66841e61 100644 --- a/server/src/admission.rs +++ b/server/src/admission.rs @@ -9,8 +9,8 @@ //! IPv4 address, an IPv6 /64), like every other per-source control in this crate. use crate::common::{ip_bucket, log}; -use std::collections::HashMap; -use std::sync::Mutex; +use std::collections::{HashMap, VecDeque}; +use std::sync::{LazyLock, Mutex}; use std::time::{Duration, Instant}; /// A refusal is logged at most this often per pool. Every refused socket used to cost a @@ -90,14 +90,7 @@ impl Pool { /// Count one refusal; `Some(n)` when a line is due, `n` being the refusals it reports. fn note_refusal(&self, now: Instant) -> Option { - let mut r = self.refusals.lock().unwrap_or_else(|e| e.into_inner()); - r.unlogged += 1; - let due = r.last_logged.map_or(true, |t| now.duration_since(t) >= REFUSAL_LOG_EVERY); - if !due { - return None; - } - r.last_logged = Some(now); - Some(std::mem::take(&mut r.unlogged)) + note(&mut self.refusals.lock().unwrap_or_else(|e| e.into_inner()), now) } /// Slots held in the whole pool. @@ -117,6 +110,110 @@ impl Pool { } } +/// MTA's flood guard for its HTTP server, at both servers' accepts: `CHTTPD::ShouldAllowConnection` +/// keeps a `CConnectHistory` of 20 connects per 10 s per address and ignores an address that goes +/// past it for a minute (CHTTPD.cpp:40, CMainConfig.cpp:66). A source that churns connections then +/// costs no pool slot and no TLS handshake. A client of ours opens three at once at most (a browser +/// open), and a heartbeat every 30 s. The same shape guards game joins (coop/net/connect_history.cpp). +pub struct ConnectHistory { + max: usize, + period: Duration, + ban: Duration, + state: Mutex, +} + +#[derive(Default)] +struct History { + per_addr: HashMap, + last_sweep: Option, + refusals: Refusals, +} + +#[derive(Default)] +struct Recent { + connects: VecDeque, + ignored_until: Option, +} + +/// Addresses tracked at once. Past it a new address is admitted untracked (the pools' per-address +/// shares still hold it): refusing it instead would let a source-diverse flood lock every newcomer +/// out, the failure the lobby rate map had to be rid of. +const MAX_TRACKED: usize = 50_000; + +/// The one history each server process keeps, for every listener it runs. +pub static CONNECTS: LazyLock = + LazyLock::new(|| ConnectHistory::new(20, Duration::from_secs(10), Duration::from_secs(60))); + +impl ConnectHistory { + pub fn new(max: usize, period: Duration, ban: Duration) -> ConnectHistory { + ConnectHistory { max, period, ban, state: Mutex::new(History::default()) } + } + + /// Whether a connect from `ip` at `now` is admitted. The one past `max` inside `period` is + /// refused and starts the address's ignored minute, logged once. + pub fn allow(&self, ip: &str, now: Instant) -> bool { + let key = ip_bucket(ip); + let mut h = self.state.lock().unwrap_or_else(|e| e.into_inner()); + self.sweep(&mut h, now); + if !h.per_addr.contains_key(&key) && h.per_addr.len() >= MAX_TRACKED { + return true; + } + let recent = h.per_addr.entry(key).or_default(); + if recent.ignored_until.is_some_and(|until| now < until) { + return false; + } + recent.ignored_until = None; + while recent.connects.front().is_some_and(|t| now.duration_since(*t) >= self.period) { + recent.connects.pop_front(); + } + if recent.connects.len() < self.max { + recent.connects.push_back(now); + return true; + } + recent.connects.clear(); + recent.ignored_until = Some(now + self.ban); + if let Some(n) = note(&mut h.refusals, now) { + let tail = if n > 1 { format!(" ({n} addresses since the last line)") } else { String::new() }; + log(&format!( + "[{ip}] connect flood: more than {} inside {} s, ignored for {} s{tail}", + self.max, + self.period.as_secs(), + self.ban.as_secs() + )); + } + false + } + + /// At most once a second, forget the addresses that are neither ignored nor recent. + fn sweep(&self, h: &mut History, now: Instant) { + if h.last_sweep.is_some_and(|t| now.duration_since(t) < Duration::from_secs(1)) { + return; + } + h.last_sweep = Some(now); + let period = self.period; + h.per_addr.retain(|_, r| { + r.ignored_until.is_some_and(|until| now < until) + || r.connects.back().is_some_and(|t| now.duration_since(*t) < period) + }); + } + + #[cfg(test)] + fn tracked(&self) -> usize { + self.state.lock().unwrap().per_addr.len() + } +} + +/// One more refusal into `r`: `Some(n)` when a line is due, `n` being the refusals it reports. +fn note(r: &mut Refusals, now: Instant) -> Option { + r.unlogged += 1; + let due = r.last_logged.map_or(true, |t| now.duration_since(t) >= REFUSAL_LOG_EVERY); + if !due { + return None; + } + r.last_logged = Some(now); + Some(std::mem::take(&mut r.unlogged)) +} + impl Drop for Slot<'_> { fn drop(&mut self) { let mut held = self.pool.held.lock().unwrap_or_else(|e| e.into_inner()); @@ -132,8 +229,30 @@ impl Drop for Slot<'_> { #[cfg(test)] mod tests { - use super::{Pool, REFUSAL_LOG_EVERY}; - use std::time::Instant; + use super::{ConnectHistory, Pool, REFUSAL_LOG_EVERY}; + use std::time::{Duration, Instant}; + + #[test] + fn a_connect_flood_is_ignored_for_the_ban_and_alone() { + let h = ConnectHistory::new(3, Duration::from_secs(10), Duration::from_secs(60)); + let t0 = Instant::now(); + assert!((0..3).all(|i| h.allow("203.0.113.7", t0 + Duration::from_millis(i))), "under the cap"); + assert!(!h.allow("203.0.113.7", t0 + Duration::from_secs(1)), "the fourth inside the period passed"); + assert!(h.allow("198.51.100.1", t0 + Duration::from_secs(1)), "another address paid for the flood"); + assert!(!h.allow("203.0.113.7", t0 + Duration::from_secs(59)), "admitted inside the ban"); + assert!(h.allow("203.0.113.7", t0 + Duration::from_secs(62)), "still ignored after the ban"); + } + + #[test] + fn connects_age_out_of_the_period() { + let h = ConnectHistory::new(2, Duration::from_secs(10), Duration::from_secs(60)); + let t0 = Instant::now(); + assert!(h.allow("192.0.2.1", t0) && h.allow("192.0.2.1", t0 + Duration::from_secs(5))); + assert!(h.allow("192.0.2.1", t0 + Duration::from_secs(11)), "a connect older than the period still counted"); + // The sweep forgets an address with nothing recent and no ban. + assert!(h.allow("192.0.2.9", t0 + Duration::from_secs(30))); + assert_eq!(h.tracked(), 1, "a stale address outlived the sweep"); + } #[test] fn one_address_cannot_take_the_whole_pool() { diff --git a/server/src/bin/master.rs b/server/src/bin/master.rs index 3d7789aa7..cf77cb5e5 100644 --- a/server/src/bin/master.rs +++ b/server/src/bin/master.rs @@ -11,7 +11,7 @@ //! ever held across an await — while still using the multi-thread tokio runtime. -use coop_server::admission::Pool; +use coop_server::admission::{Pool, CONNECTS}; use coop_server::common::{clamp_str, env_int, env_str, log}; use coop_server::http_transport::{ json_bytes, parse_head, read_head, write_response, HeadErr, HeadReject, CONNS, HTTP_TIMEOUT, MAX_BODY, @@ -237,6 +237,9 @@ async fn serve_plain(listener: TcpListener, pool: &'static Pool, budget: Duratio match listener.accept().await { Ok((stream, addr)) => { let peer_ip = addr.ip().to_string(); + if !CONNECTS.allow(&peer_ip, std::time::Instant::now()) { + continue; + } let Some(slot) = pool.admit_or_log(&peer_ip) else { continue }; log(&format!("accept [listener=plain] [{peer_ip}]")); let deadline = Instant::now() + budget; @@ -266,6 +269,9 @@ async fn serve_tls( match listener.accept().await { Ok((stream, addr)) => { let peer_ip = addr.ip().to_string(); + if !CONNECTS.allow(&peer_ip, std::time::Instant::now()) { + continue; + } let Some(slot) = pool.admit_or_log(&peer_ip) else { continue }; let deadline = Instant::now() + budget; let acceptor = acceptor.clone(); diff --git a/server/src/bin/signaling.rs b/server/src/bin/signaling.rs index 713c671b5..332fe1ca4 100644 --- a/server/src/bin/signaling.rs +++ b/server/src/bin/signaling.rs @@ -58,7 +58,7 @@ //! 5s drain timeout — a slow/stalled destination can never head-of-line block a //! sender, and memory per destination is bounded by the channel capacity. -use coop_server::admission::{Pool, Slot}; +use coop_server::admission::{Pool, Slot, CONNECTS}; use coop_server::common::{clamp_str, ct_eq, env_int, env_str, identity_shape_ok, log, token_hex}; use coop_server::registration::check_registration_proof; use coop_server::tls; @@ -506,6 +506,9 @@ async fn serve_plain(listener: TcpListener, pool: &'static Pool, budget: Duratio match listener.accept().await { Ok((stream, addr)) => { let ip = addr.ip().to_string(); + if !CONNECTS.allow(&ip, std::time::Instant::now()) { + continue; + } let Some(pending) = pool.admit_or_log(&ip) else { continue }; log(&format!("accept [listener=plain] [{ip}]")); set_keepalive(&stream); @@ -531,6 +534,9 @@ async fn serve_tls( match listener.accept().await { Ok((stream, addr)) => { let ip = addr.ip().to_string(); + if !CONNECTS.allow(&ip, std::time::Instant::now()) { + continue; + } let Some(pending) = pool.admit_or_log(&ip) else { continue }; set_keepalive(&stream); let deadline = Instant::now() + budget; From c18396dac61f6fe4377f100dc20595a1f557f707 Mon Sep 17 00:00:00 2001 From: pelmentor Date: Sat, 10 Oct 2026 16:35:20 +0600 Subject: [PATCH 123/306] [server] the pools are sized from the bounded cost of a connection With a connection's memory bounded, the master's pool takes more slots and keeps 16 per address (a browser open is three requests). The relay keeps its totals and lowers each address's share: 4 pre-auth slots and 8 registrations (a lobby of four plus a reconnect). The connect history tracks a bounded number of addresses. master-server.md says what holds a pool's memory. The totals are set from measured costs in a later commit. Evidence: cargo test passes 55. Co-Authored-By: Claude Opus 5.5 --- docs/master-server.md | 5 +++-- server/src/admission.rs | 8 ++++---- server/src/bin/signaling.rs | 13 ++++++++----- server/src/http_transport.rs | 12 ++++++++---- 4 files changed, 23 insertions(+), 15 deletions(-) diff --git a/docs/master-server.md b/docs/master-server.md index df63dd359..4ab75628e 100644 --- a/docs/master-server.md +++ b/docs/master-server.md @@ -75,8 +75,9 @@ distinct from the secret session id, control characters stripped and strings cla bodies and headers, and the forwarded-for header trusted only from a loopback proxy. Both services admit a connection at accept, before its TLS handshake, into a pool with a total and a per-address cap, and one deadline bounds the handshake and everything read before the answer (on the relay, -before registration): a source that opens sockets and sends nothing holds a few slots for fifteen -seconds, never the whole pool. +before registration). Filling a pool takes tens of addresses (48 for the master, 32 and 64 for the +relay's two), each connection's memory is bounded so a full pool fits the service's limit, and an +address that makes more than 20 connects inside ten seconds is ignored for a minute. ### Direct and brokered diff --git a/server/src/admission.rs b/server/src/admission.rs index c66841e61..ffeb0d386 100644 --- a/server/src/admission.rs +++ b/server/src/admission.rs @@ -135,10 +135,10 @@ struct Recent { ignored_until: Option, } -/// Addresses tracked at once. Past it a new address is admitted untracked (the pools' per-address -/// shares still hold it): refusing it instead would let a source-diverse flood lock every newcomer -/// out, the failure the lobby rate map had to be rid of. -const MAX_TRACKED: usize = 50_000; +/// Addresses tracked at once: ~400 bytes each (20 connect times and the key), so ~8 MiB at most. +/// Past it a new address is admitted untracked (the pools' per-address shares still hold it): +/// refusing it instead would let a source-diverse flood lock every newcomer out. +const MAX_TRACKED: usize = 20_000; /// The one history each server process keeps, for every listener it runs. pub static CONNECTS: LazyLock = diff --git a/server/src/bin/signaling.rs b/server/src/bin/signaling.rs index 332fe1ca4..cea507526 100644 --- a/server/src/bin/signaling.rs +++ b/server/src/bin/signaling.rs @@ -107,11 +107,14 @@ const MAX_QUEUED_PER_DEST: usize = 128 * 1024; static TOKEN: LazyLock = LazyLock::new(|| env_str("COOP_SIGNALING_TOKEN", "")); static CLIENTS: LazyLock>> = LazyLock::new(|| Mutex::new(HashMap::new())); // Connections that have not proved their key yet: the small pool an anonymous flood can fill, -// taken at accept, and one address may hold only a few of its slots. -static PENDING: LazyLock = LazyLock::new(|| Pool::new("pre-auth", 128, 8)); -// Registered peers: one per install while it hosts or joins, so 32 per address is a large -// household or a carrier NAT. -static AUTHED: LazyLock = LazyLock::new(|| Pool::new("authed", 512, 32)); +// taken at accept. A slot holds at most a 64 KiB TLS handshake buffer and a 513-byte line, and one +// address 4 of the 128, so filling it takes 32 addresses. +static PENDING: LazyLock = LazyLock::new(|| Pool::new("pre-auth", 128, 4)); +// Registered peers: one per install while it hosts or joins. A full lobby is 4, and a reconnect +// over a half-open socket is admitted before the stale registration it replaces is gone, so 8 per +// address; filling the pool takes 64 addresses. Its total stays: 512 x (128 KiB of queue + ~33 KiB +// of TLS) is ~80 MiB of the unit's 128 MiB. +static AUTHED: LazyLock = LazyLock::new(|| Pool::new("authed", 512, 8)); static CONN_SEQ: AtomicU64 = AtomicU64::new(1); /// Bounded, cancel-safe line reader over the connection's read half. On `select!` diff --git a/server/src/http_transport.rs b/server/src/http_transport.rs index 3a40ef289..6100ec695 100644 --- a/server/src/http_transport.rs +++ b/server/src/http_transport.rs @@ -17,10 +17,14 @@ pub const MAX_HEADER: usize = 16 * 1024; /// room and keeps a body's parse small: a `serde_json::Value` can cost 16x its bytes /// (tests/conn_cost.rs: a 64 KiB array of zeros parses to 1 MiB). pub const MAX_BODY: usize = 4 * 1024; -pub const MAX_CONNS: usize = 256; -/// Per source address. A client holds at most a few short requests at once (a list refresh, a -/// heartbeat, a join), so 16 leaves room for a household or a carrier NAT while keeping one source -/// from holding the whole pool. +/// The pool, sized against the unit's 128 MiB. One connection holds at most ~66 KiB: during the TLS +/// handshake rustls buffers up to 64 KiB of a handshake message (its `MAX_HANDSHAKE_SIZE`); after +/// it, one record (~17 KiB), 16 KiB of received plaintext, 16 KiB unsent (`tls::SEND_BUFFER_LIMIT`) +/// and the 16 KiB head or the 4 KiB body. 768 x 66 KiB is ~50 MiB, leaving room for the lobby +/// maps and the connect history (tests/conn_cost.rs measures the parts that are ours). +pub const MAX_CONNS: usize = 768; +/// Per source address. A browser open is three requests at once (lobbies, latest, thanks), so a +/// household of four needs 12; filling the pool then takes 48 addresses. pub const MAX_CONNS_PER_ADDR: u32 = 16; /// Every connection the master holds, the ones still in their TLS handshake included. From fd31928b37272d240084313df45247cb8f20c94b Mon Sep 17 00:00:00 2001 From: pelmentor Date: Sat, 10 Oct 2026 17:01:47 +0600 Subject: [PATCH 124/306] [server] the lobby list ignores a version query no client sends GET /v1/lobbies answers the cached shared body for every request and does not read the query string. Every released client and the current mod ask for the whole list, so the version filter, filter_lobbies and the cached row set behind it are gone; the snapshot returns only the body it serves. A test asks with ?version=zzz and still gets the hosted lobby. Evidence: cargo test --offline in server/: 43+4+7+1 pass, no warnings. Co-Authored-By: Claude Sonnet 5.5 --- server/src/bin/master.rs | 31 +++++++++++-------------------- server/src/lobby.rs | 30 ++++++++---------------------- 2 files changed, 19 insertions(+), 42 deletions(-) diff --git a/server/src/bin/master.rs b/server/src/bin/master.rs index cf77cb5e5..e3f9f42f5 100644 --- a/server/src/bin/master.rs +++ b/server/src/bin/master.rs @@ -12,14 +12,14 @@ use coop_server::admission::{Pool, CONNECTS}; -use coop_server::common::{clamp_str, env_int, env_str, log}; +use coop_server::common::{env_int, env_str, log}; use coop_server::http_transport::{ json_bytes, parse_head, read_head, write_response, HeadErr, HeadReject, CONNS, HTTP_TIMEOUT, MAX_BODY, MAX_HEADER, }; use coop_server::lobby::{ - dispatch_post, filter_lobbies, lobbies_snapshot, lock_state, resolve_client_ip, sweeper, - LATEST_MOD, LATEST_PROTO, LATEST_URL, MAX_VERSION, + dispatch_post, lobbies_snapshot, lock_state, resolve_client_ip, sweeper, LATEST_MOD, LATEST_PROTO, + LATEST_URL, }; use coop_server::master_config::CFG; use coop_server::thanks::{thanks_answer, Answer}; @@ -44,11 +44,10 @@ async fn handle(mut stream: S, peer_ip: Strin Ok(Err(HeadErr::Closed)) | Err(_) => return, // eof / timeout }; - let (method, path, query, content_length, client_ip) = match parse_head(&head) { + let (method, path, content_length, client_ip) = match parse_head(&head) { Ok(h) => ( h.method.to_string(), h.path.to_string(), - h.query.to_string(), h.content_length.map(str::to_string), resolve_client_ip(&peer_ip, h.x_real_ip, h.x_forwarded_for), ), @@ -63,7 +62,7 @@ async fn handle(mut stream: S, peer_ip: Strin }; // A connection holds its head or its body, never both. drop(head); - let (method, path, query) = (method.as_str(), path.as_str(), query.as_str()); + let (method, path) = (method.as_str(), path.as_str()); // ---- POST: read, parse and answer in one expression, so the body and its parse are dropped // before the answer's write, which can wait out the deadline on a client that stops reading. @@ -82,23 +81,13 @@ async fn handle(mut stream: S, peer_ip: Strin // ---- route ---- if method == "GET" && path == "/v1/lobbies" { - let mut vf = String::new(); - for kv in query.split('&') { - if let Some(rest) = kv.strip_prefix("version=") { - vf = clamp_str(rest, MAX_VERSION); - } - } - // Refresh + snapshot under the lock; serialize the response OFF the lock (L5). - let (all_body, rows) = { + // Refresh + snapshot under the lock; the write happens OFF the lock (L5). The query string + // is not read: every client asks for the whole list. + let body = { let mut state = lock_state(); lobbies_snapshot(&mut state) }; - if vf.is_empty() { - write_response(&mut stream, 200, &all_body).await; - } else { - let body = filter_lobbies(&rows, &vf); - write_response(&mut stream, 200, &body).await; - } + write_response(&mut stream, 200, &body).await; } else if method == "GET" && path == "/v1/latest" { // Env-overridable release info (resolved once; see the LATEST_* comment above). static LATEST: LazyLock<(i64, String, String)> = LazyLock::new(|| { @@ -361,6 +350,8 @@ mod tests { let id = lobby["lobbyId"].as_str().expect("no lobbyId").to_string(); let (status, list) = ask(b"GET /v1/lobbies HTTP/1.1\r\nHost: x\r\n\r\n".to_vec()).await; assert!(status == 200 && list.contains(&id), "the new lobby is not listed"); + let (status, list) = ask(b"GET /v1/lobbies?version=zzz HTTP/1.1\r\nHost: x\r\n\r\n".to_vec()).await; + assert!(status == 200 && list.contains(&id), "a query string changed the list"); let over = vec![b' '; super::MAX_BODY + 1]; assert_eq!(ask(post("/v1/host", &over)).await.0, 413, "a body past the cap was read"); diff --git a/server/src/lobby.rs b/server/src/lobby.rs index fa4b54d0c..53a299313 100644 --- a/server/src/lobby.rs +++ b/server/src/lobby.rs @@ -170,10 +170,9 @@ pub struct MasterState { pub lobby_by_public: HashMap, // lobbyId -> sessionId pub rate: HashMap>, // "ipbucket|class" -> recent timestamps pub cache_t: Option, - // Arc so /v1/lobbies can clone the cached body/rows cheaply under the lock and - // serialize the response OUTSIDE it (audit L5: serializing a ~200 KB body under - // the global lock serialized it against all host/join/heartbeat processing). - pub cache_rows: Arc>, + // Arc so /v1/lobbies can clone the cached body cheaply under the lock and write the + // response OUTSIDE it (audit L5: serializing a ~200 KB body under the global lock + // serialized it against all host/join/heartbeat processing). pub cache_all_body: Arc>, } @@ -184,7 +183,6 @@ impl MasterState { lobby_by_public: HashMap::new(), rate: HashMap::new(), cache_t: None, - cache_rows: Arc::new(Vec::new()), cache_all_body: Arc::new(Vec::new()), } } @@ -621,11 +619,10 @@ pub fn build_rows(state: &MasterState) -> Vec { rows } -/// Refresh the /v1/lobbies cache if stale and return cheap Arc handles to the cached -/// unfiltered body + the row set. Called UNDER the lock; the caller serializes any -/// version-filtered response from the returned rows AFTER dropping the lock (audit -/// L5 — the ~200 KB clone/serialize no longer runs under the global mutex). -pub fn lobbies_snapshot(state: &mut MasterState) -> (Arc>, Arc>) { +/// Refresh the /v1/lobbies cache if stale and return a cheap Arc handle to the cached +/// body. Called UNDER the lock; the caller writes it AFTER dropping the lock (audit +/// L5 — the ~200 KB body is no longer cloned or serialized under the global mutex). +pub fn lobbies_snapshot(state: &mut MasterState) -> Arc> { let now = Instant::now(); let stale = match state.cache_t { Some(t) => now.duration_since(t) >= LOBBIES_CACHE_TTL, @@ -635,20 +632,9 @@ pub fn lobbies_snapshot(state: &mut MasterState) -> (Arc>, Arc Vec { - let filtered: Vec<&Value> = rows - .iter() - .filter(|r| r.get("version").and_then(|v| v.as_str()) == Some(version_filter)) - .collect(); - serde_json::to_vec(&json!({"lobbies": filtered})).unwrap_or_default() + Arc::clone(&state.cache_all_body) } pub fn dispatch_post(path: &str, ip: &str, body: &Value) -> Option<(u16, Value)> { From 111243d1441fd98388c787f072a3d126db557a68 Mon Sep 17 00:00:00 2001 From: pelmentor Date: Sat, 10 Oct 2026 17:04:19 +0600 Subject: [PATCH 125/306] [server] connection costs are measured, and the pools fit them conn_cost.rs now measures the 4 KiB body in its four shapes, a connect history entry at 20 connects, and the TLS buffers, and fails past a bound about 25% above each figure. The pools rest on those rows: the master holds 512 connections (~87 KiB each, ~44 MiB), a relay destination queues 64 KiB (an authed connection ~129 KiB, 512 of them ~65 MiB), and the connect history tracks 10,000 addresses (~640 B each, ~6.3 MiB). The sums in the comments and the pool sentence in the docs follow. Evidence: cargo test --offline in server/: 43+4+7+1 pass; conn_cost prints 64.6/56.1/64.6/4.6 KiB bodies, 0.8 KiB for one tracked address, 605 B each over 1792. Co-Authored-By: Claude Sonnet 5.5 --- docs/master-server.md | 2 +- server/README.md | 2 +- server/src/admission.rs | 8 ++--- server/src/bin/signaling.rs | 25 ++++++++-------- server/src/http_transport.rs | 20 +++++++------ server/tests/conn_cost.rs | 57 +++++++++++++++++++++++++++++------- 6 files changed, 76 insertions(+), 38 deletions(-) diff --git a/docs/master-server.md b/docs/master-server.md index 4ab75628e..0d36113f0 100644 --- a/docs/master-server.md +++ b/docs/master-server.md @@ -75,7 +75,7 @@ distinct from the secret session id, control characters stripped and strings cla bodies and headers, and the forwarded-for header trusted only from a loopback proxy. Both services admit a connection at accept, before its TLS handshake, into a pool with a total and a per-address cap, and one deadline bounds the handshake and everything read before the answer (on the relay, -before registration). Filling a pool takes tens of addresses (48 for the master, 32 and 64 for the +before registration). Filling a pool takes tens of addresses (32 for the master, 32 and 64 for the relay's two), each connection's memory is bounded so a full pool fits the service's limit, and an address that makes more than 20 connects inside ten seconds is ignored for a minute. diff --git a/server/README.md b/server/README.md index 0938f9d35..5316f7b56 100644 --- a/server/README.md +++ b/server/README.md @@ -70,7 +70,7 @@ answer, or on the relay before registration; a relay destination's queue is capp - Evicting a duplicate identity drops the peer's relay `Sender`, which closes the channel and shuts the old socket down at once; nothing waits on the OS to unwedge a stale reader. - A bounded per-destination channel with drop-on-full: a slow destination can never - head-of-line-block a sender, and memory per destination is hard-bounded at 128 KiB, so every + head-of-line-block a sender, and memory per destination is hard-bounded at 64 KiB, so every destination together fits the unit's memory cap. - Typed JSON through serde replaces hand-rolled key validation. - `LATEST_PROTO`, `LATEST_MOD` and `LATEST_URL` in the master binary's source are the operator diff --git a/server/src/admission.rs b/server/src/admission.rs index ffeb0d386..cd64d3f58 100644 --- a/server/src/admission.rs +++ b/server/src/admission.rs @@ -135,10 +135,10 @@ struct Recent { ignored_until: Option, } -/// Addresses tracked at once: ~400 bytes each (20 connect times and the key), so ~8 MiB at most. -/// Past it a new address is admitted untracked (the pools' per-address shares still hold it): -/// refusing it instead would let a source-diverse flood lock every newcomer out. -const MAX_TRACKED: usize = 20_000; +/// Addresses tracked at once: ~640 bytes each at 20 connects (tests/conn_cost.rs), so ~6.3 MiB at +/// most. Past it a new address is admitted untracked (the pools' per-address shares still hold +/// it): refusing it instead would let a source-diverse flood lock every newcomer out. +const MAX_TRACKED: usize = 10_000; /// The one history each server process keeps, for every listener it runs. pub static CONNECTS: LazyLock = diff --git a/server/src/bin/signaling.rs b/server/src/bin/signaling.rs index cea507526..93f11c77b 100644 --- a/server/src/bin/signaling.rs +++ b/server/src/bin/signaling.rs @@ -91,29 +91,28 @@ const KEEPALIVE_INTERVAL: Duration = Duration::from_secs(5); // identity, which GNS renders as `gen:` + 64 hex = 68 chars. At 64 the greeting // was refused outright, which reads as "P2P is down" rather than as a length cap. const MAX_IDENTITY: usize = 80; -// Bounded relay backlog per destination. Sizing (security audit 2026-07-16, S-1): the -// prior 1024-deep queue of up-to-64 KiB items let ONE token-holder pin ~64 MiB per -// stalled destination. Signaling payloads (SDP/ICE trickle) are a few KB, so a -// registered peer's line is capped at MAX_RELAY_PAYLOAD, the frame relayed from it is -// that line's own bytes behind the sender's name (`relay_frame`), and the queue holds -// RELAY_QUEUE frames AND MAX_QUEUED_PER_DEST bytes. The byte cap is what bounds the whole relay: frames alone (64 x 8 KiB) -// times the 512 authed slots came to 256 MiB, twice the unit's MemoryMax, so a few -// sources that register and stop reading could have it OOM-killed. 128 KiB per -// destination is 64 MiB over every slot, and still holds 64 small ICE frames. +// Bounded relay backlog per destination. Signaling payloads (SDP/ICE trickle) are a few KB, so +// a registered peer's line is capped at MAX_RELAY_PAYLOAD, the frame relayed from it is that +// line's own bytes behind the sender's name (`relay_frame`), and the queue holds RELAY_QUEUE +// frames AND MAX_QUEUED_PER_DEST bytes. The byte cap is what bounds the whole relay: frames +// alone (64 x 8 KiB) times the 512 authed slots would be 256 MiB, twice the unit's MemoryMax. +// 64 KiB per destination is still 8 frames at the 8 KiB cap and dozens of real ICE frames. An +// authed connection is then ~129 KiB (this queue plus ~65 KiB of TLS buffers and line, measured +// in tests/conn_cost.rs), 512 of them ~65 MiB. const RELAY_QUEUE: usize = 64; const MAX_RELAY_PAYLOAD: usize = 8 * 1024; -const MAX_QUEUED_PER_DEST: usize = 128 * 1024; +const MAX_QUEUED_PER_DEST: usize = 64 * 1024; static TOKEN: LazyLock = LazyLock::new(|| env_str("COOP_SIGNALING_TOKEN", "")); static CLIENTS: LazyLock>> = LazyLock::new(|| Mutex::new(HashMap::new())); // Connections that have not proved their key yet: the small pool an anonymous flood can fill, // taken at accept. A slot holds at most a 64 KiB TLS handshake buffer and a 513-byte line, and one -// address 4 of the 128, so filling it takes 32 addresses. +// address 4 of the 128, so filling it takes 32 addresses: 128 x ~72 KiB is ~9 MiB. static PENDING: LazyLock = LazyLock::new(|| Pool::new("pre-auth", 128, 4)); // Registered peers: one per install while it hosts or joins. A full lobby is 4, and a reconnect // over a half-open socket is admitted before the stale registration it replaces is gone, so 8 per -// address; filling the pool takes 64 addresses. Its total stays: 512 x (128 KiB of queue + ~33 KiB -// of TLS) is ~80 MiB of the unit's 128 MiB. +// address; filling the pool takes 64 addresses. 512 x (64 KiB of queue + ~65 KiB of TLS and line) +// is ~65 MiB of the unit's 128 MiB. static AUTHED: LazyLock = LazyLock::new(|| Pool::new("authed", 512, 8)); static CONN_SEQ: AtomicU64 = AtomicU64::new(1); diff --git a/server/src/http_transport.rs b/server/src/http_transport.rs index 6100ec695..6a73dbf93 100644 --- a/server/src/http_transport.rs +++ b/server/src/http_transport.rs @@ -15,16 +15,18 @@ pub const MAX_HEADER: usize = 16 * 1024; /// The largest body a client of ours sends is the host announce: under 1.5 KiB with its name and /// world at their 63-byte input caps, fully escaped (coop/net/lobby_announcer.cpp). 4 KiB leaves /// room and keeps a body's parse small: a `serde_json::Value` can cost 16x its bytes -/// (tests/conn_cost.rs: a 64 KiB array of zeros parses to 1 MiB). +/// (tests/conn_cost.rs: a 4 KiB array of zeros parses to 65 KiB). pub const MAX_BODY: usize = 4 * 1024; -/// The pool, sized against the unit's 128 MiB. One connection holds at most ~66 KiB: during the TLS -/// handshake rustls buffers up to 64 KiB of a handshake message (its `MAX_HANDSHAKE_SIZE`); after -/// it, one record (~17 KiB), 16 KiB of received plaintext, 16 KiB unsent (`tls::SEND_BUFFER_LIMIT`) -/// and the 16 KiB head or the 4 KiB body. 768 x 66 KiB is ~50 MiB, leaving room for the lobby -/// maps and the connect history (tests/conn_cost.rs measures the parts that are ours). -pub const MAX_CONNS: usize = 768; -/// Per source address. A browser open is three requests at once (lobbies, latest, thanks), so a -/// household of four needs 12; filling the pool then takes 48 addresses. +/// The pool, sized against the unit's 128 MiB from what tests/conn_cost.rs measures. One connection +/// holds at most ~87 KiB. During the TLS handshake rustls buffers up to 64.8 KiB of a handshake +/// message. After it, one record is held in 32 KiB (the buffer doubles past 16 KiB), plus 16 KiB +/// of received plaintext, 16 KiB unsent (`tls::SEND_BUFFER_LIMIT`) and the 16 KiB head: 80 KiB. +/// Either way the task's future adds ~7 KiB. 512 x ~87 KiB is ~44 MiB, leaving room for the lobby +/// maps and the connect history. +pub const MAX_CONNS: usize = 512; +/// Per source address. A browser open is three requests at once (lobbies, latest, thanks) and the +/// list refreshes every 5 s (`server_browser_native.cpp:123`), so a household of four needs 12; +/// 512 / 16 = 32 addresses to fill the pool. pub const MAX_CONNS_PER_ADDR: u32 = 16; /// Every connection the master holds, the ones still in their TLS handshake included. diff --git a/server/tests/conn_cost.rs b/server/tests/conn_cost.rs index 2b1fc7753..9babc2ed9 100644 --- a/server/tests/conn_cost.rs +++ b/server/tests/conn_cost.rs @@ -2,10 +2,13 @@ //! allocator, for the pool sizes to rest on. One test function, so nothing else allocates while a //! phase is counted. `cargo test --test conn_cost -- --nocapture` prints the table. +use coop_server::admission::ConnectHistory; +use coop_server::http_transport::MAX_BODY; use serde_json::Value; use std::alloc::{GlobalAlloc, Layout, System}; use std::sync::atomic::{AtomicIsize, Ordering}; use std::sync::Arc; +use std::time::{Duration, Instant}; use tokio_rustls::rustls::server::{ClientHello, ResolvesServerCert}; use tokio_rustls::rustls::sign::CertifiedKey; use tokio_rustls::rustls::{ServerConfig, ServerConnection}; @@ -99,20 +102,54 @@ fn connection_costs() { let kib = |n: isize| n as f64 / 1024.0; println!("phase | heap held"); - // The master's parsed body, for a body at the cap and at 8 KiB. - for cap in [64 * 1024usize, 8 * 1024] { - for shape in ["array of zeros", "many keys", "empty objects", "one long string"] { - let raw = body(shape, cap); - let (v, cost) = held(|| serde_json::from_slice::(&raw).expect("the fixture is JSON")); - println!("master body {cap} B, {shape}: {:.1} KiB parsed", kib(cost)); - drop(v); - } + // Each row prints what it measured and fails the test past its bound, which sits about 25% + // above today's figure: a pool size in the servers rests on these rows. + let bound = |what: &str, cost: isize, limit_kib: f64| { + println!("{what}: {:.1} KiB (bound {limit_kib:.1})", kib(cost)); + assert!(kib(cost) <= limit_kib, "{what} cost {:.1} KiB, past its bound of {limit_kib:.1}", kib(cost)); + }; + + // The master's parsed body, at the cap it enforces. + let cap = MAX_BODY; + for (shape, limit) in [ + ("array of zeros", 81.0), + ("many keys", 70.0), + ("empty objects", 81.0), + ("one long string", 5.8), + ] { + let raw = body(shape, cap); + let (v, cost) = held(|| serde_json::from_slice::(&raw).expect("the fixture is JSON")); + bound(&format!("master body {cap} B, {shape}, parsed"), cost, limit); + drop(v); } + // One tracked address that connected 20 times, the flood guard's per-address cost. + let history = ConnectHistory::new(20, Duration::from_secs(10), Duration::from_secs(60)); + let now = Instant::now(); + let ((), tracked) = held(|| { + for _ in 0..20 { + assert!(history.allow("203.0.113.7", now)); + } + }); + bound("connect history, one address, 20 connects", tracked, 1.0); + + // The same per address once the table is full of them: what the tracked cap multiplies. + const MANY: usize = 1792; + let history = ConnectHistory::new(20, Duration::from_secs(10), Duration::from_secs(60)); + let ((), many) = held(|| { + for a in 0..MANY { + let ip = format!("10.{}.{}.{}", a >> 16, (a >> 8) & 255, a & 255); + for _ in 0..20 { + assert!(history.allow(&ip, now)); + } + } + }); + bound(&format!("connect history, {MANY} addresses, 20 connects each"), many, 1320.0); + // A rustls server connection holding one partial 16 KiB handshake record. let cfg = Arc::new(ServerConfig::builder().with_no_client_auth().with_cert_resolver(Arc::new(NoCert))); let (conn, new_cost) = held(|| ServerConnection::new(cfg.clone()).expect("a server connection")); - println!("tls server connection, new: {:.1} KiB", kib(new_cost)); + bound("tls server connection, new", new_cost, 1.0); let mut conn = conn; let mut record = vec![0x16u8, 0x03, 0x01, 0x40, 0x00]; record.resize(5 + 16 * 1024 - 1, 0); @@ -124,6 +161,6 @@ fn connection_costs() { } } }); - println!("tls server connection, a 16 KiB record pending: +{:.1} KiB", kib(buffered)); + bound("tls server connection, a 16 KiB record pending", buffered, 40.0); drop(conn); } From 5ed4f07dff124717cc78a4623d75d06de2513c90 Mon Sep 17 00:00:00 2001 From: pelmentor Date: Sat, 10 Oct 2026 17:05:52 +0600 Subject: [PATCH 126/306] [server] the connect-flood limits are settings, with an exclude list The flood guard reads COOP_CONNECT_FLOOD_MAX (default 20, 0 is off) and COOP_CONNECT_FLOOD_EXCLUDE (comma-separated addresses, never limited or tracked) when first used, the shape of MTA's httpdosthreshold and http_dos_exclude. ConnectHistory takes its tracked cap as a parameter, and a full table logs once per second when it admits a newcomer untracked. The flood line is written after the lock is released. The client-rate comment says what a browser and a host actually send. New tests cover the exclude list, a max of 0 and the tracked cap. Evidence: cargo test --offline in server/: 46+4+7+1 pass, no warnings. Co-Authored-By: Claude Sonnet 5.5 --- docs/master-server.md | 4 +- server/README.md | 4 +- server/src/admission.rs | 128 ++++++++++++++++++++++++++++++++------ server/tests/conn_cost.rs | 4 +- 4 files changed, 116 insertions(+), 24 deletions(-) diff --git a/docs/master-server.md b/docs/master-server.md index 0d36113f0..d76c4b81e 100644 --- a/docs/master-server.md +++ b/docs/master-server.md @@ -77,7 +77,9 @@ admit a connection at accept, before its TLS handshake, into a pool with a total cap, and one deadline bounds the handshake and everything read before the answer (on the relay, before registration). Filling a pool takes tens of addresses (32 for the master, 32 and 64 for the relay's two), each connection's memory is bounded so a full pool fits the service's limit, and an -address that makes more than 20 connects inside ten seconds is ignored for a minute. +address that makes more than 20 connects inside ten seconds is ignored for a minute +(`COOP_CONNECT_FLOOD_MAX` sets the 20, 0 turns it off; `COOP_CONNECT_FLOOD_EXCLUDE` lists +addresses that are never limited). ### Direct and brokered diff --git a/server/README.md b/server/README.md index 5316f7b56..482182602 100644 --- a/server/README.md +++ b/server/README.md @@ -61,7 +61,9 @@ control-character strip and codepoint clamp on every string; per-(address, class rate limits; a global LRU and per-address lobby caps; a `LOBBY_TTL` sweep; bounded header and body; connection admission at accept (`admission.rs`: a total and a per-address cap, taken before the TLS handshake) under one deadline that covers the handshake and every read before the -answer, or on the relay before registration; a relay destination's queue is capped in bytes. +answer, or on the relay before registration; a relay destination's queue is capped in bytes; +an address past `COOP_CONNECT_FLOOD_MAX` connects (default 20, 0 is off) inside ten seconds is +ignored for a minute, and `COOP_CONNECT_FLOOD_EXCLUDE` (comma-separated addresses) is never limited. ## Design notes diff --git a/server/src/admission.rs b/server/src/admission.rs index cd64d3f58..0ccf73a80 100644 --- a/server/src/admission.rs +++ b/server/src/admission.rs @@ -8,7 +8,7 @@ //! stops ONE source from filling the whole pool; the address is coarsened by `ip_bucket` (a full //! IPv4 address, an IPv6 /64), like every other per-source control in this crate. -use crate::common::{ip_bucket, log}; +use crate::common::{env_int, env_str, ip_bucket, log}; use std::collections::{HashMap, VecDeque}; use std::sync::{LazyLock, Mutex}; use std::time::{Duration, Instant}; @@ -113,12 +113,19 @@ impl Pool { /// MTA's flood guard for its HTTP server, at both servers' accepts: `CHTTPD::ShouldAllowConnection` /// keeps a `CConnectHistory` of 20 connects per 10 s per address and ignores an address that goes /// past it for a minute (CHTTPD.cpp:40, CMainConfig.cpp:66). A source that churns connections then -/// costs no pool slot and no TLS handshake. A client of ours opens three at once at most (a browser -/// open), and a heartbeat every 30 s. The same shape guards game joins (coop/net/connect_history.cpp). +/// costs no pool slot and no TLS handshake. A browser opens three connections and refreshes every +/// 5 s, a host heartbeats every 30 s, so 20 per 10 s admits about ten open browsers behind one +/// address; raise it for a known shared address, or exclude it. Both are settings, as in MTA +/// (`httpdosthreshold`, `httpdosexclude`, CMainConfig.cpp:247-252). The same shape guards game +/// joins (coop/net/connect_history.cpp). pub struct ConnectHistory { + // 0 turns the guard off. max: usize, period: Duration, ban: Duration, + max_tracked: usize, + // `ip_bucket` keys of the addresses that are always allowed and never tracked. + exclude: Vec, state: Mutex, } @@ -127,6 +134,8 @@ struct History { per_addr: HashMap, last_sweep: Option, refusals: Refusals, + // Newcomers admitted untracked because the table was full. + untracked: Refusals, } #[derive(Default)] @@ -140,27 +149,72 @@ struct Recent { /// it): refusing it instead would let a source-diverse flood lock every newcomer out. const MAX_TRACKED: usize = 10_000; -/// The one history each server process keeps, for every listener it runs. -pub static CONNECTS: LazyLock = - LazyLock::new(|| ConnectHistory::new(20, Duration::from_secs(10), Duration::from_secs(60))); +/// The flood guard's default: connects allowed per period. `COOP_CONNECT_FLOOD_MAX` overrides it. +const DEFAULT_FLOOD_MAX: i64 = 20; + +/// The one history each server process keeps, for every listener it runs. Built from +/// `COOP_CONNECT_FLOOD_MAX` (0 turns the guard off) and `COOP_CONNECT_FLOOD_EXCLUDE` (a comma +/// separated list of addresses that are never limited) the first time it is used. +pub static CONNECTS: LazyLock = LazyLock::new(|| { + let max = usize::try_from(env_int("COOP_CONNECT_FLOOD_MAX", DEFAULT_FLOOD_MAX)) + .unwrap_or(DEFAULT_FLOOD_MAX as usize); + ConnectHistory::new(max, Duration::from_secs(10), Duration::from_secs(60), MAX_TRACKED) + .with_exclude(&env_str("COOP_CONNECT_FLOOD_EXCLUDE", "")) +}); impl ConnectHistory { - pub fn new(max: usize, period: Duration, ban: Duration) -> ConnectHistory { - ConnectHistory { max, period, ban, state: Mutex::new(History::default()) } + pub fn new(max: usize, period: Duration, ban: Duration, max_tracked: usize) -> ConnectHistory { + ConnectHistory { + max, + period, + ban, + max_tracked, + exclude: Vec::new(), + state: Mutex::new(History::default()), + } + } + + /// Never limit or track the addresses in `list`, comma separated. + pub fn with_exclude(mut self, list: &str) -> ConnectHistory { + self.exclude = list.split(',').map(str::trim).filter(|a| !a.is_empty()).map(ip_bucket).collect(); + self } /// Whether a connect from `ip` at `now` is admitted. The one past `max` inside `period` is - /// refused and starts the address's ignored minute, logged once. + /// refused and starts the address's ignored minute, logged once. The key is `ip_bucket` (an + /// IPv6 /64), where the mod's own port keys the full address. pub fn allow(&self, ip: &str, now: Instant) -> bool { + if self.max == 0 { + return true; + } let key = ip_bucket(ip); + if self.exclude.contains(&key) { + return true; + } + let (allowed, line) = self.decide(key, ip, now); + // The line is written after the lock is gone: a log write is a syscall. + if let Some(line) = line { + log(&line); + } + allowed + } + + /// `allow`'s decision under the lock, and the log line it owes, if any. + fn decide(&self, key: String, ip: &str, now: Instant) -> (bool, Option) { let mut h = self.state.lock().unwrap_or_else(|e| e.into_inner()); self.sweep(&mut h, now); - if !h.per_addr.contains_key(&key) && h.per_addr.len() >= MAX_TRACKED { - return true; + if !h.per_addr.contains_key(&key) && h.per_addr.len() >= self.max_tracked { + // MTA's map is unbounded (CConnectHistory.h:14); this one is bounded for the unit's + // memory and fails open, logged once per line-second. + let line = note(&mut h.untracked, now).map(|n| { + let tail = if n > 1 { format!(" ({n} since the last line)") } else { String::new() }; + format!("[{ip}] connect history full ({} addresses): admitted untracked{tail}", self.max_tracked) + }); + return (true, line); } let recent = h.per_addr.entry(key).or_default(); if recent.ignored_until.is_some_and(|until| now < until) { - return false; + return (false, None); } recent.ignored_until = None; while recent.connects.front().is_some_and(|t| now.duration_since(*t) >= self.period) { @@ -168,20 +222,24 @@ impl ConnectHistory { } if recent.connects.len() < self.max { recent.connects.push_back(now); - return true; + return (true, None); } + // MTA keeps the stamps through the ban and can re-ban on them in the sweep gap + // (CConnectHistory.cpp:51-57,107-111); the stamps are cleared here so a ban does not. recent.connects.clear(); recent.ignored_until = Some(now + self.ban); - if let Some(n) = note(&mut h.refusals, now) { + // The address's open connections are left alone: the pools bound them, where MTA's EHS + // stops them (ehs.cpp:977-991) and a registered relay peer must not pay for a neighbour. + let line = note(&mut h.refusals, now).map(|n| { let tail = if n > 1 { format!(" ({n} addresses since the last line)") } else { String::new() }; - log(&format!( + format!( "[{ip}] connect flood: more than {} inside {} s, ignored for {} s{tail}", self.max, self.period.as_secs(), self.ban.as_secs() - )); - } - false + ) + }); + (false, line) } /// At most once a second, forget the addresses that are neither ignored nor recent. @@ -234,7 +292,7 @@ mod tests { #[test] fn a_connect_flood_is_ignored_for_the_ban_and_alone() { - let h = ConnectHistory::new(3, Duration::from_secs(10), Duration::from_secs(60)); + let h = ConnectHistory::new(3, Duration::from_secs(10), Duration::from_secs(60), 100); let t0 = Instant::now(); assert!((0..3).all(|i| h.allow("203.0.113.7", t0 + Duration::from_millis(i))), "under the cap"); assert!(!h.allow("203.0.113.7", t0 + Duration::from_secs(1)), "the fourth inside the period passed"); @@ -245,7 +303,7 @@ mod tests { #[test] fn connects_age_out_of_the_period() { - let h = ConnectHistory::new(2, Duration::from_secs(10), Duration::from_secs(60)); + let h = ConnectHistory::new(2, Duration::from_secs(10), Duration::from_secs(60), 100); let t0 = Instant::now(); assert!(h.allow("192.0.2.1", t0) && h.allow("192.0.2.1", t0 + Duration::from_secs(5))); assert!(h.allow("192.0.2.1", t0 + Duration::from_secs(11)), "a connect older than the period still counted"); @@ -254,6 +312,36 @@ mod tests { assert_eq!(h.tracked(), 1, "a stale address outlived the sweep"); } + #[test] + fn an_excluded_address_is_allowed_past_the_max_and_leaves_no_entry() { + let h = ConnectHistory::new(2, Duration::from_secs(10), Duration::from_secs(60), 100) + .with_exclude(" 192.0.2.77 , ,2001:db8:1:2::5"); + let t0 = Instant::now(); + assert!((0..10).all(|_| h.allow("192.0.2.77", t0)), "an excluded address was limited"); + assert!((0..10).all(|_| h.allow("2001:db8:1:2::ffff", t0)), "an excluded /64 was limited"); + assert_eq!(h.tracked(), 0, "an excluded address was tracked"); + assert!(h.allow("192.0.2.1", t0) && h.allow("192.0.2.1", t0)); + assert!(!h.allow("192.0.2.1", t0), "the exclude list spared another address"); + } + + #[test] + fn a_max_of_zero_allows_everything() { + let h = ConnectHistory::new(0, Duration::from_secs(10), Duration::from_secs(60), 100); + let t0 = Instant::now(); + assert!((0..100).all(|_| h.allow("192.0.2.1", t0)), "the guard was on at 0"); + assert_eq!(h.tracked(), 0, "a disabled guard tracked an address"); + } + + #[test] + fn past_the_tracked_cap_a_newcomer_is_allowed_and_not_tracked() { + let h = ConnectHistory::new(1, Duration::from_secs(10), Duration::from_secs(60), 2); + let t0 = Instant::now(); + assert!(h.allow("192.0.2.1", t0) && h.allow("192.0.2.2", t0)); + assert!((0..5).all(|_| h.allow("192.0.2.3", t0)), "a newcomer past the cap was limited"); + assert_eq!(h.tracked(), 2, "a newcomer past the cap was tracked"); + assert!(!h.allow("192.0.2.1", t0), "a tracked address escaped its limit"); + } + #[test] fn one_address_cannot_take_the_whole_pool() { let pool = Pool::new("test", 10, 2); diff --git a/server/tests/conn_cost.rs b/server/tests/conn_cost.rs index 9babc2ed9..1c4c998d1 100644 --- a/server/tests/conn_cost.rs +++ b/server/tests/conn_cost.rs @@ -124,7 +124,7 @@ fn connection_costs() { } // One tracked address that connected 20 times, the flood guard's per-address cost. - let history = ConnectHistory::new(20, Duration::from_secs(10), Duration::from_secs(60)); + let history = ConnectHistory::new(20, Duration::from_secs(10), Duration::from_secs(60), 10_000); let now = Instant::now(); let ((), tracked) = held(|| { for _ in 0..20 { @@ -135,7 +135,7 @@ fn connection_costs() { // The same per address once the table is full of them: what the tracked cap multiplies. const MANY: usize = 1792; - let history = ConnectHistory::new(20, Duration::from_secs(10), Duration::from_secs(60)); + let history = ConnectHistory::new(20, Duration::from_secs(10), Duration::from_secs(60), 10_000); let ((), many) = held(|| { for a in 0..MANY { let ip = format!("10.{}.{}.{}", a >> 16, (a >> 8) & 255, a & 255); From 1d1821f0701cf34c76c341b38319e973b57d42d1 Mon Sep 17 00:00:00 2001 From: pelmentor Date: Sat, 10 Oct 2026 17:07:26 +0600 Subject: [PATCH 127/306] [server] read_post_body's edge cases are pinned, the TLS doc fixed Tests pin a POST body's edges through the master's handler: no Content-Length is a 413, a zero length is an empty object, bytes past the declared length are not parsed, and a JSON array is a 400. The TLS send limit moves below its own doc, so acceptor_from_env's comment is whole again. The relay_line comment says what holds now: the reader's line cap and the frame as the line's bytes. Evidence: cargo test --offline in server/: 46+5+7+1 pass, no warnings. Co-Authored-By: Claude Sonnet 5.5 --- server/src/bin/master.rs | 17 +++++++++++++++++ server/src/bin/signaling.rs | 7 +++---- server/src/http_transport.rs | 7 ++++--- server/src/tls.rs | 18 +++++++++--------- 4 files changed, 33 insertions(+), 16 deletions(-) diff --git a/server/src/bin/master.rs b/server/src/bin/master.rs index e3f9f42f5..c45f5ae4d 100644 --- a/server/src/bin/master.rs +++ b/server/src/bin/master.rs @@ -359,6 +359,23 @@ mod tests { assert_eq!(ask(b"GET / HTTP/1.1\r\nA: \xff\r\n\r\n".to_vec()).await.0, 400, "a non-text head was read"); } + #[tokio::test] + async fn a_post_body_is_read_to_its_declared_length_and_must_be_an_object() { + let leave = |head_tail: &str, body: &[u8]| { + let mut r = format!("POST /v1/leave HTTP/1.1\r\nHost: x\r\n{head_tail}\r\n").into_bytes(); + r.extend_from_slice(body); + r + }; + assert_eq!(ask(leave("", b"")).await.0, 413, "a POST without a Content-Length was read"); + // An empty body is an empty object: the leave handler answers it as it answers `{}`. + let (empty, _) = ask(leave("Content-Length: 0\r\n", b"")).await; + assert_eq!(empty, 403, "an empty body was not treated as an empty object"); + // Bytes past the declared length are not part of the body. + let (cut, _) = ask(leave("Content-Length: 2\r\n", b"{}trailing junk")).await; + assert_eq!(cut, empty, "bytes past the Content-Length reached the parser"); + assert_eq!(ask(leave("Content-Length: 2\r\n", b"[]")).await.0, 400, "a JSON array was taken as a body"); + } + #[tokio::test] async fn a_client_that_never_sends_a_client_hello_is_dropped_at_the_deadline() { static POOL: LazyLock = LazyLock::new(|| Pool::new("test", 8, 8)); diff --git a/server/src/bin/signaling.rs b/server/src/bin/signaling.rs index 93f11c77b..5d755e093 100644 --- a/server/src/bin/signaling.rs +++ b/server/src/bin/signaling.rs @@ -401,10 +401,9 @@ async fn handle( /// payload and retires its registration after 45 s of silence, so a self-route guard here would /// make every host reconnect on a timer forever. fn relay_line(sender: &str, line: &[u8]) { - // S-1 (audit 2026-07-16): drop over-length lines BEFORE building/queuing the - // relayed frame. The reader already ends a connection whose line outgrows - // MAX_RELAY_PAYLOAD, but a line that arrives whole in one read can pass that - // check by up to a read's length. Real SDP/ICE frames are a few KB. + // The reader ends a connection whose line outgrows MAX_RELAY_PAYLOAD + 1 bytes, so the one + // line it can still hand over past the cap, exactly one byte over, is dropped here before + // a frame is built. The frame is the line's own bytes behind the sender's name. if line.len() > MAX_RELAY_PAYLOAD { return; } diff --git a/server/src/http_transport.rs b/server/src/http_transport.rs index 6a73dbf93..8da88792d 100644 --- a/server/src/http_transport.rs +++ b/server/src/http_transport.rs @@ -12,9 +12,10 @@ use tokio::time::timeout; /// the body. The answer's write is bounded separately (`write_response`). pub const HTTP_TIMEOUT: Duration = Duration::from_secs(15); pub const MAX_HEADER: usize = 16 * 1024; -/// The largest body a client of ours sends is the host announce: under 1.5 KiB with its name and -/// world at their 63-byte input caps, fully escaped (coop/net/lobby_announcer.cpp). 4 KiB leaves -/// room and keeps a body's parse small: a `serde_json::Value` can cost 16x its bytes +/// The largest body a client of ours sends is the host announce (coop/net/lobby_announcer.cpp): +/// under 1.5 KiB with its name at 63 bytes, fully escaped. The name field is a `char[64]` +/// (server_browser.cpp:56) and the server clamps names to 63 characters (`lobby::MAX_NAME`). +/// 4 KiB leaves room and keeps a body's parse small: a `serde_json::Value` can cost 16x its bytes /// (tests/conn_cost.rs: a 4 KiB array of zeros parses to 65 KiB). pub const MAX_BODY: usize = 4 * 1024; /// The pool, sized against the unit's 128 MiB from what tests/conn_cost.rs measures. One connection diff --git a/server/src/tls.rs b/server/src/tls.rs index 8bee50076..83bec6d98 100644 --- a/server/src/tls.rs +++ b/server/src/tls.rs @@ -23,15 +23,6 @@ use tokio_rustls::rustls::pki_types::{CertificateDer, PrivateKeyDer}; use tokio_rustls::rustls::ServerConfig; use tokio_rustls::TlsAcceptor; -/// Build a `TlsAcceptor` from `COOP_TLS_CERT` + `COOP_TLS_KEY` (PEM paths, as -/// handed over by systemd `LoadCredential`). -/// -/// Returns `None` when EITHER var is unset/empty — that is the deliberate -/// plaintext-only mode used by the local differential harness and by the -/// parallel-port cutover window, NOT a silent downgrade of a configured -/// listener: a var that IS set but unreadable is a FATAL error (below), because -/// silently falling back to cleartext on a box that was meant to serve TLS is -/// exactly the failure this tier exists to prevent. /// TLS records a connection may hold unsent before a write waits for the peer to read: rustls' /// default is 64 KiB, the size of a whole lobby list, held by any client that stops reading. pub const SEND_BUFFER_LIMIT: usize = 16 * 1024; @@ -41,6 +32,15 @@ pub fn limit_send_buffer(conn: &mut tokio_rustls::rustls::ServerConnection) { conn.set_buffer_limit(Some(SEND_BUFFER_LIMIT)); } +/// Build a `TlsAcceptor` from `COOP_TLS_CERT` + `COOP_TLS_KEY` (PEM paths, as +/// handed over by systemd `LoadCredential`). +/// +/// Returns `None` when EITHER var is unset/empty — that is the deliberate +/// plaintext-only mode used by the local differential harness and by the +/// parallel-port cutover window, NOT a silent downgrade of a configured +/// listener: a var that IS set but unreadable is a FATAL error (below), because +/// silently falling back to cleartext on a box that was meant to serve TLS is +/// exactly the failure this tier exists to prevent. pub fn acceptor_from_env() -> Option { let cert_path = env_str("COOP_TLS_CERT", ""); let key_path = env_str("COOP_TLS_KEY", ""); From e4c8d90f2a9447f50fdd4968830801e2cdaa47fc Mon Sep 17 00:00:00 2001 From: pelmentor Date: Sat, 10 Oct 2026 17:08:39 +0600 Subject: [PATCH 128/306] [build] the server's tests run in the repo gates repo-gates.yml gains a last step that runs the master's and the relay's tests with the lockfile held, so a change to either server is checked on every push and pull request, beside the python gates. Evidence: workflow_expr_gate.py PASS; cargo test --locked --offline in server/ passes 46+5+7+1. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/repo-gates.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/repo-gates.yml b/.github/workflows/repo-gates.yml index 5084d145e..f5a99f305 100644 --- a/.github/workflows/repo-gates.yml +++ b/.github/workflows/repo-gates.yml @@ -164,3 +164,6 @@ jobs: - name: Translation template gate (the template is current; only allowlisted files extract; call sites follow the rules) run: python3 .github/ci/l10n_pot.py --check + + - name: Server tests (the master and the relay) + run: cargo test --locked --manifest-path server/Cargo.toml From 4c906847d5219b0a4ab32f1d9a9005d9fd742d22 Mon Sep 17 00:00:00 2001 From: pelmentor Date: Sat, 10 Oct 2026 17:32:08 +0600 Subject: [PATCH 129/306] [server] the flood setting is bounded and logged, the master sum stated COOP_CONNECT_FLOOD_MAX is capped at 40, since each tracked address keeps that many connect times, and a negative value takes the default; the guard logs what it was built with, and an exclude entry that is not an address is logged and skipped. Head drops its unused query field. The master's pool comment and the relay's queue comment count rustls' buffers, the head or line and the task alike. master-server.md no longer offers a version-filtered list. Evidence: cargo test passes 59; the repo gates pass 17, the server tests among them. Co-Authored-By: Claude Opus 5.5 --- docs/master-server.md | 11 +++++------ server/src/admission.rs | 37 ++++++++++++++++++++++++++---------- server/src/bin/signaling.rs | 6 +++--- server/src/http_transport.rs | 22 ++++++++++----------- server/src/tls.rs | 4 ++-- 5 files changed, 47 insertions(+), 33 deletions(-) diff --git a/docs/master-server.md b/docs/master-server.md index d76c4b81e..41910530d 100644 --- a/docs/master-server.md +++ b/docs/master-server.md @@ -75,11 +75,10 @@ distinct from the secret session id, control characters stripped and strings cla bodies and headers, and the forwarded-for header trusted only from a loopback proxy. Both services admit a connection at accept, before its TLS handshake, into a pool with a total and a per-address cap, and one deadline bounds the handshake and everything read before the answer (on the relay, -before registration). Filling a pool takes tens of addresses (32 for the master, 32 and 64 for the -relay's two), each connection's memory is bounded so a full pool fits the service's limit, and an -address that makes more than 20 connects inside ten seconds is ignored for a minute -(`COOP_CONNECT_FLOOD_MAX` sets the 20, 0 turns it off; `COOP_CONNECT_FLOOD_EXCLUDE` lists -addresses that are never limited). +before registration). Each connection's memory is bounded so a full pool fits the service's +limit, and an address that makes more than 20 connects inside ten seconds is ignored for a minute +(`COOP_CONNECT_FLOOD_MAX` sets the 20, at most 40, 0 turns it off; `COOP_CONNECT_FLOOD_EXCLUDE` +lists addresses the guard never limits, the pools' caps still applying). ### Direct and brokered @@ -138,7 +137,7 @@ locally for a scripted run, and serves a synthetic lobby list to the browser in | Route | Who | Carries | |---|---|---| | `/v1/host`, `/v1/heartbeat`, `/v1/visibility`, `/v1/leave` | the host | the announce, which carries the host's build claim (`build_sha`, `build_key`, `build_sig`, optional) and `listed`; the keepalive with players, their links, listing and the TURN credential the host holds, answered with its renewal; the listing flip; the leave | -| `/v1/lobbies`, `/v1/join` | the client | the list for a version, each row with one word for how its players reach the host (relay, direct, lan, or none before anyone has joined) and the counts behind it, and `build_sha`, `build_key` and `build_sig`: the host's own claim of its build, recorded as sent: a field of the wrong type reads as none (no hash, key 0, an empty signature), and a claim whose hash, key and signature do not fit together is dropped with one log line (the master verifies nothing); the dialing information for a lobby | +| `/v1/lobbies`, `/v1/join` | the client | the list, each row with one word for how its players reach the host (relay, direct, lan, or none before anyone has joined) and the counts behind it, and `build_sha`, `build_key` and `build_sig`: the host's own claim of its build, recorded as sent: a field of the wrong type reads as none (no hash, key 0, an empty signature), and a claim whose hash, key and signature do not fit together is dropped with one log line (the master verifies nothing); the dialing information for a lobby | | `/v1/latest`, `/healthz` | the client; an operator | the released pair; liveness | | `/v1/thanks` | the client, on opening the browser | the thanks list's text | | the signaling lines | both peers | the greeting, the challenge and its proof, the forwarded candidates | diff --git a/server/src/admission.rs b/server/src/admission.rs index 0ccf73a80..27c1c3361 100644 --- a/server/src/admission.rs +++ b/server/src/admission.rs @@ -8,7 +8,7 @@ //! stops ONE source from filling the whole pool; the address is coarsened by `ip_bucket` (a full //! IPv4 address, an IPv6 /64), like every other per-source control in this crate. -use crate::common::{env_int, env_str, ip_bucket, log}; +use crate::common::{clamp_str, env_int, env_str, ip_bucket, log}; use std::collections::{HashMap, VecDeque}; use std::sync::{LazyLock, Mutex}; use std::time::{Duration, Instant}; @@ -116,7 +116,7 @@ impl Pool { /// costs no pool slot and no TLS handshake. A browser opens three connections and refreshes every /// 5 s, a host heartbeats every 30 s, so 20 per 10 s admits about ten open browsers behind one /// address; raise it for a known shared address, or exclude it. Both are settings, as in MTA -/// (`httpdosthreshold`, `httpdosexclude`, CMainConfig.cpp:247-252). The same shape guards game +/// (`httpdosthreshold`, `http_dos_exclude`, CMainConfig.cpp:247-252). The same shape guards game /// joins (coop/net/connect_history.cpp). pub struct ConnectHistory { // 0 turns the guard off. @@ -151,15 +151,26 @@ const MAX_TRACKED: usize = 10_000; /// The flood guard's default: connects allowed per period. `COOP_CONNECT_FLOOD_MAX` overrides it. const DEFAULT_FLOOD_MAX: i64 = 20; +/// The highest `COOP_CONNECT_FLOOD_MAX` taken: a tracked address keeps up to that many connect +/// times, so the history's memory (`MAX_TRACKED` addresses) grows with it; at 40 it is ~9 MiB. +const FLOOD_MAX_CEILING: i64 = 40; /// The one history each server process keeps, for every listener it runs. Built from -/// `COOP_CONNECT_FLOOD_MAX` (0 turns the guard off) and `COOP_CONNECT_FLOOD_EXCLUDE` (a comma -/// separated list of addresses that are never limited) the first time it is used. +/// `COOP_CONNECT_FLOOD_MAX` (0 turns the guard off, where MTA's setting is 1..10000, +/// CMainConfig.cpp:248; capped at `FLOOD_MAX_CEILING`) and `COOP_CONNECT_FLOOD_EXCLUDE` (a comma +/// separated list of addresses the guard never limits; the pools' caps still apply, and an IPv6 +/// entry covers its /64) the first time it is used, and logged as built. pub static CONNECTS: LazyLock = LazyLock::new(|| { - let max = usize::try_from(env_int("COOP_CONNECT_FLOOD_MAX", DEFAULT_FLOOD_MAX)) - .unwrap_or(DEFAULT_FLOOD_MAX as usize); - ConnectHistory::new(max, Duration::from_secs(10), Duration::from_secs(60), MAX_TRACKED) - .with_exclude(&env_str("COOP_CONNECT_FLOOD_EXCLUDE", "")) + let asked = env_int("COOP_CONNECT_FLOOD_MAX", DEFAULT_FLOOD_MAX); + let max = if asked < 0 { DEFAULT_FLOOD_MAX } else { asked.min(FLOOD_MAX_CEILING) } as usize; + let history = ConnectHistory::new(max, Duration::from_secs(10), Duration::from_secs(60), MAX_TRACKED) + .with_exclude(&env_str("COOP_CONNECT_FLOOD_EXCLUDE", "")); + log(&format!( + "connect flood guard: {} (asked {asked}), {} excluded address(es)", + if max == 0 { "off".to_string() } else { format!("{max} per 10 s, then 60 s ignored") }, + history.exclude.len() + )); + history }); impl ConnectHistory { @@ -176,7 +187,13 @@ impl ConnectHistory { /// Never limit or track the addresses in `list`, comma separated. pub fn with_exclude(mut self, list: &str) -> ConnectHistory { - self.exclude = list.split(',').map(str::trim).filter(|a| !a.is_empty()).map(ip_bucket).collect(); + for entry in list.split(',').map(str::trim).filter(|a| !a.is_empty()) { + if entry.parse::().is_ok() { + self.exclude.push(ip_bucket(entry)); + } else { + log(&format!("connect flood guard: exclude entry '{}' is not an address, ignored", clamp_str(entry, 64))); + } + } self } @@ -315,7 +332,7 @@ mod tests { #[test] fn an_excluded_address_is_allowed_past_the_max_and_leaves_no_entry() { let h = ConnectHistory::new(2, Duration::from_secs(10), Duration::from_secs(60), 100) - .with_exclude(" 192.0.2.77 , ,2001:db8:1:2::5"); + .with_exclude(" 192.0.2.77 , ,2001:db8:1:2::5, not-an-address"); let t0 = Instant::now(); assert!((0..10).all(|_| h.allow("192.0.2.77", t0)), "an excluded address was limited"); assert!((0..10).all(|_| h.allow("2001:db8:1:2::ffff", t0)), "an excluded /64 was limited"); diff --git a/server/src/bin/signaling.rs b/server/src/bin/signaling.rs index 5d755e093..ea97df4b4 100644 --- a/server/src/bin/signaling.rs +++ b/server/src/bin/signaling.rs @@ -96,9 +96,9 @@ const MAX_IDENTITY: usize = 80; // line's own bytes behind the sender's name (`relay_frame`), and the queue holds RELAY_QUEUE // frames AND MAX_QUEUED_PER_DEST bytes. The byte cap is what bounds the whole relay: frames // alone (64 x 8 KiB) times the 512 authed slots would be 256 MiB, twice the unit's MemoryMax. -// 64 KiB per destination is still 8 frames at the 8 KiB cap and dozens of real ICE frames. An -// authed connection is then ~129 KiB (this queue plus ~65 KiB of TLS buffers and line, measured -// in tests/conn_cost.rs), 512 of them ~65 MiB. +// 64 KiB per destination is still 8 frames at the 8 KiB cap. On the master's basis (rustls' +// buffers, the line, the task) an authed connection is at most ~143 KiB with this queue, 512 of +// them ~72 MiB; with the pre-auth pool and the connect history the relay's worst case is ~99 MiB. const RELAY_QUEUE: usize = 64; const MAX_RELAY_PAYLOAD: usize = 8 * 1024; const MAX_QUEUED_PER_DEST: usize = 64 * 1024; diff --git a/server/src/http_transport.rs b/server/src/http_transport.rs index 8da88792d..89af854d4 100644 --- a/server/src/http_transport.rs +++ b/server/src/http_transport.rs @@ -13,17 +13,16 @@ use tokio::time::timeout; pub const HTTP_TIMEOUT: Duration = Duration::from_secs(15); pub const MAX_HEADER: usize = 16 * 1024; /// The largest body a client of ours sends is the host announce (coop/net/lobby_announcer.cpp): -/// under 1.5 KiB with its name at 63 bytes, fully escaped. The name field is a `char[64]` -/// (server_browser.cpp:56) and the server clamps names to 63 characters (`lobby::MAX_NAME`). +/// under 2 KiB with its name at the native host window's 96-byte cap (host_session_settings.cpp:59), +/// fully escaped; the server then clamps names to 63 characters (`lobby::MAX_NAME`). /// 4 KiB leaves room and keeps a body's parse small: a `serde_json::Value` can cost 16x its bytes /// (tests/conn_cost.rs: a 4 KiB array of zeros parses to 65 KiB). pub const MAX_BODY: usize = 4 * 1024; -/// The pool, sized against the unit's 128 MiB from what tests/conn_cost.rs measures. One connection -/// holds at most ~87 KiB. During the TLS handshake rustls buffers up to 64.8 KiB of a handshake -/// message. After it, one record is held in 32 KiB (the buffer doubles past 16 KiB), plus 16 KiB -/// of received plaintext, 16 KiB unsent (`tls::SEND_BUFFER_LIMIT`) and the 16 KiB head: 80 KiB. -/// Either way the task's future adds ~7 KiB. 512 x ~87 KiB is ~44 MiB, leaving room for the lobby -/// maps and the connect history. +/// The pool, sized against the unit's 128 MiB. One connection holds at most ~87 KiB: the larger of +/// rustls' handshake buffer (64.8 KiB) and, after it, one record held in 32 KiB plus 16 KiB of +/// received plaintext, 16 KiB unsent (`tls::SEND_BUFFER_LIMIT`) and the 16 KiB head (80 KiB), plus +/// the task's ~7 KiB. 512 of them ~44 MiB; with the connect history and the lobby maps the master's +/// worst case is ~71 MiB (tests/conn_cost.rs measures the parts that are ours). pub const MAX_CONNS: usize = 512; /// Per source address. A browser open is three requests at once (lobbies, latest, thanks) and the /// list refreshes every 5 s (`server_browser_native.cpp:123`), so a household of four needs 12; @@ -120,7 +119,6 @@ pub enum HeadErr { pub struct Head<'a> { pub method: &'a str, pub path: &'a str, - pub query: &'a str, pub content_length: Option<&'a str>, pub x_real_ip: Option<&'a str>, pub x_forwarded_for: Option<&'a str>, @@ -141,8 +139,8 @@ pub fn parse_head(head: &[u8]) -> Result, HeadReject> { (Some(m), Some(p), Some(_)) => (m, p), _ => return Err(HeadReject::BadRequestLine), }; - let (path, query) = raw_path.split_once('?').unwrap_or((raw_path, "")); - let mut h = Head { method, path, query, content_length: None, x_real_ip: None, x_forwarded_for: None }; + let path = raw_path.split_once('?').map_or(raw_path, |(p, _)| p); + let mut h = Head { method, path, content_length: None, x_real_ip: None, x_forwarded_for: None }; for ln in lines { let Some((k, v)) = ln.split_once(':') else { continue }; let k = k.trim(); @@ -170,7 +168,7 @@ mod tests { let h = parse_head(b"POST /v1/host?x=1 HTTP/1.1\r\nContent-Length: 12\r\nX-Real-IP: 1.2.3.4\r\nA: b") .ok() .expect("a well-formed head was refused"); - assert_eq!((h.method, h.path, h.query), ("POST", "/v1/host", "x=1")); + assert_eq!((h.method, h.path), ("POST", "/v1/host"), "the query string stayed in the path"); assert_eq!((h.content_length, h.x_real_ip, h.x_forwarded_for), (Some("12"), Some("1.2.3.4"), None)); let again = parse_head(b"GET / HTTP/1.1\r\ncontent-length: 1\r\nCONTENT-LENGTH: 2").ok().unwrap(); assert_eq!(again.content_length, Some("2"), "a repeated header did not keep its last value"); diff --git a/server/src/tls.rs b/server/src/tls.rs index 83bec6d98..57205df8a 100644 --- a/server/src/tls.rs +++ b/server/src/tls.rs @@ -23,8 +23,8 @@ use tokio_rustls::rustls::pki_types::{CertificateDer, PrivateKeyDer}; use tokio_rustls::rustls::ServerConfig; use tokio_rustls::TlsAcceptor; -/// TLS records a connection may hold unsent before a write waits for the peer to read: rustls' -/// default is 64 KiB, the size of a whole lobby list, held by any client that stops reading. +/// TLS records a connection may hold unsent before a write waits for the peer to read. rustls' +/// default is 64 KiB; 16 KiB keeps a full pool's sum (`http_transport::MAX_CONNS`) bounded. pub const SEND_BUFFER_LIMIT: usize = 16 * 1024; /// For `TlsAcceptor::accept_with`: every accepted connection gets `SEND_BUFFER_LIMIT`. From 6dcfded044e17c7656d0672c2b807414ea7c95ca Mon Sep 17 00:00:00 2001 From: pelmentor Date: Sat, 10 Oct 2026 17:39:38 +0600 Subject: [PATCH 130/306] [server] the flood setting is tested, logged at start, its cost measured flood_max_from turns COOP_CONNECT_FLOOD_MAX into the connects allowed (0 off, negative the default, 40 the ceiling) and is unit-tested; both servers build the guard at start so its settings line is logged then, quoting the raw setting. The exclude test asserts that an entry which is not an address is skipped. conn_cost.rs measures a tracked address at the ceiling too, and the memory comments give their addends: the master's worst case ~71 MiB, the relay's ~99 MiB, of the unit's 128 MiB. Evidence: cargo test passes 60; a tracked address costs 605 B at 20 connects and 1,117 B at 40 (conn_cost.rs). Co-Authored-By: Claude Opus 5.5 --- server/README.md | 2 +- server/src/admission.rs | 26 +++++++++++++++++++++----- server/src/bin/master.rs | 2 ++ server/src/bin/signaling.rs | 8 +++++--- server/src/http_transport.rs | 5 +++-- server/tests/conn_cost.rs | 13 +++++++++++++ 6 files changed, 45 insertions(+), 11 deletions(-) diff --git a/server/README.md b/server/README.md index 482182602..4ae2d7e91 100644 --- a/server/README.md +++ b/server/README.md @@ -62,7 +62,7 @@ rate limits; a global LRU and per-address lobby caps; a `LOBBY_TTL` sweep; bound body; connection admission at accept (`admission.rs`: a total and a per-address cap, taken before the TLS handshake) under one deadline that covers the handshake and every read before the answer, or on the relay before registration; a relay destination's queue is capped in bytes; -an address past `COOP_CONNECT_FLOOD_MAX` connects (default 20, 0 is off) inside ten seconds is +an address past `COOP_CONNECT_FLOOD_MAX` connects (default 20, at most 40, 0 is off) inside ten seconds is ignored for a minute, and `COOP_CONNECT_FLOOD_EXCLUDE` (comma-separated addresses) is never limited. ## Design notes diff --git a/server/src/admission.rs b/server/src/admission.rs index 27c1c3361..21b2b9a44 100644 --- a/server/src/admission.rs +++ b/server/src/admission.rs @@ -152,22 +152,29 @@ const MAX_TRACKED: usize = 10_000; /// The flood guard's default: connects allowed per period. `COOP_CONNECT_FLOOD_MAX` overrides it. const DEFAULT_FLOOD_MAX: i64 = 20; /// The highest `COOP_CONNECT_FLOOD_MAX` taken: a tracked address keeps up to that many connect -/// times, so the history's memory (`MAX_TRACKED` addresses) grows with it; at 40 it is ~9 MiB. +/// times, so the history's memory (`MAX_TRACKED` addresses) grows with it (tests/conn_cost.rs +/// measures a tracked address at the default and at this ceiling). const FLOOD_MAX_CEILING: i64 = 40; +/// The connects per period a `COOP_CONNECT_FLOOD_MAX` value asks for: 0 is off, a negative value +/// takes the default, and nothing goes past the ceiling. +fn flood_max_from(asked: i64) -> usize { + if asked < 0 { DEFAULT_FLOOD_MAX as usize } else { asked.min(FLOOD_MAX_CEILING) as usize } +} + /// The one history each server process keeps, for every listener it runs. Built from /// `COOP_CONNECT_FLOOD_MAX` (0 turns the guard off, where MTA's setting is 1..10000, /// CMainConfig.cpp:248; capped at `FLOOD_MAX_CEILING`) and `COOP_CONNECT_FLOOD_EXCLUDE` (a comma /// separated list of addresses the guard never limits; the pools' caps still apply, and an IPv6 /// entry covers its /64) the first time it is used, and logged as built. pub static CONNECTS: LazyLock = LazyLock::new(|| { - let asked = env_int("COOP_CONNECT_FLOOD_MAX", DEFAULT_FLOOD_MAX); - let max = if asked < 0 { DEFAULT_FLOOD_MAX } else { asked.min(FLOOD_MAX_CEILING) } as usize; + let max = flood_max_from(env_int("COOP_CONNECT_FLOOD_MAX", DEFAULT_FLOOD_MAX)); let history = ConnectHistory::new(max, Duration::from_secs(10), Duration::from_secs(60), MAX_TRACKED) .with_exclude(&env_str("COOP_CONNECT_FLOOD_EXCLUDE", "")); log(&format!( - "connect flood guard: {} (asked {asked}), {} excluded address(es)", + "connect flood guard: {} (COOP_CONNECT_FLOOD_MAX '{}'), {} excluded address(es)", if max == 0 { "off".to_string() } else { format!("{max} per 10 s, then 60 s ignored") }, + clamp_str(&env_str("COOP_CONNECT_FLOOD_MAX", ""), 32), history.exclude.len() )); history @@ -304,7 +311,15 @@ impl Drop for Slot<'_> { #[cfg(test)] mod tests { - use super::{ConnectHistory, Pool, REFUSAL_LOG_EVERY}; + use super::{flood_max_from, ConnectHistory, Pool, DEFAULT_FLOOD_MAX, FLOOD_MAX_CEILING, REFUSAL_LOG_EVERY}; + + #[test] + fn the_flood_setting_is_off_at_zero_defaulted_when_negative_and_capped() { + assert_eq!(flood_max_from(0), 0); + assert_eq!(flood_max_from(-5), DEFAULT_FLOOD_MAX as usize); + assert_eq!(flood_max_from(25), 25); + assert_eq!(flood_max_from(i64::MAX), FLOOD_MAX_CEILING as usize); + } use std::time::{Duration, Instant}; #[test] @@ -337,6 +352,7 @@ mod tests { assert!((0..10).all(|_| h.allow("192.0.2.77", t0)), "an excluded address was limited"); assert!((0..10).all(|_| h.allow("2001:db8:1:2::ffff", t0)), "an excluded /64 was limited"); assert_eq!(h.tracked(), 0, "an excluded address was tracked"); + assert_eq!(h.exclude.len(), 2, "an entry that is not an address joined the exclude list"); assert!(h.allow("192.0.2.1", t0) && h.allow("192.0.2.1", t0)); assert!(!h.allow("192.0.2.1", t0), "the exclude list spared another address"); } diff --git a/server/src/bin/master.rs b/server/src/bin/master.rs index c45f5ae4d..ec929f190 100644 --- a/server/src/bin/master.rs +++ b/server/src/bin/master.rs @@ -153,6 +153,8 @@ async fn read_post_body( #[tokio::main] async fn main() { + // Built now, so its settings are logged at start rather than at the first connect. + LazyLock::force(&CONNECTS); // fail fast on missing secrets (same as the Python FATAL exits) if CFG.turn_secret.is_empty() { log("FATAL: COOP_TURN_SECRET not set -- refusing to mint TURN creds"); diff --git a/server/src/bin/signaling.rs b/server/src/bin/signaling.rs index ea97df4b4..9ce9bed14 100644 --- a/server/src/bin/signaling.rs +++ b/server/src/bin/signaling.rs @@ -98,7 +98,8 @@ const MAX_IDENTITY: usize = 80; // alone (64 x 8 KiB) times the 512 authed slots would be 256 MiB, twice the unit's MemoryMax. // 64 KiB per destination is still 8 frames at the 8 KiB cap. On the master's basis (rustls' // buffers, the line, the task) an authed connection is at most ~143 KiB with this queue, 512 of -// them ~72 MiB; with the pre-auth pool and the connect history the relay's worst case is ~99 MiB. +// them ~72 MiB; the pre-auth pool adds ~9 MiB, the connect history up to ~11 MiB at its setting's +// ceiling, and the process's own base an estimated ~7 MiB: the relay's worst case is ~99 MiB. const RELAY_QUEUE: usize = 64; const MAX_RELAY_PAYLOAD: usize = 8 * 1024; const MAX_QUEUED_PER_DEST: usize = 64 * 1024; @@ -111,8 +112,7 @@ static CLIENTS: LazyLock>> = LazyLock::new(|| Mutex static PENDING: LazyLock = LazyLock::new(|| Pool::new("pre-auth", 128, 4)); // Registered peers: one per install while it hosts or joins. A full lobby is 4, and a reconnect // over a half-open socket is admitted before the stale registration it replaces is gone, so 8 per -// address; filling the pool takes 64 addresses. 512 x (64 KiB of queue + ~65 KiB of TLS and line) -// is ~65 MiB of the unit's 128 MiB. +// address. 512 x ~143 KiB (MAX_QUEUED_PER_DEST's comment) is ~72 MiB of the unit's 128 MiB. static AUTHED: LazyLock = LazyLock::new(|| Pool::new("authed", 512, 8)); static CONN_SEQ: AtomicU64 = AtomicU64::new(1); @@ -456,6 +456,8 @@ fn set_keepalive(stream: &TcpStream) { #[tokio::main] async fn main() { + // Built now, so its settings are logged at start rather than at the first connect. + LazyLock::force(&CONNECTS); if TOKEN.is_empty() { log("FATAL: COOP_SIGNALING_TOKEN not set -- refusing to start an open server"); std::process::exit(1); diff --git a/server/src/http_transport.rs b/server/src/http_transport.rs index 89af854d4..67d3910a1 100644 --- a/server/src/http_transport.rs +++ b/server/src/http_transport.rs @@ -21,8 +21,9 @@ pub const MAX_BODY: usize = 4 * 1024; /// The pool, sized against the unit's 128 MiB. One connection holds at most ~87 KiB: the larger of /// rustls' handshake buffer (64.8 KiB) and, after it, one record held in 32 KiB plus 16 KiB of /// received plaintext, 16 KiB unsent (`tls::SEND_BUFFER_LIMIT`) and the 16 KiB head (80 KiB), plus -/// the task's ~7 KiB. 512 of them ~44 MiB; with the connect history and the lobby maps the master's -/// worst case is ~71 MiB (tests/conn_cost.rs measures the parts that are ours). +/// the task's ~7 KiB. 512 of them ~44 MiB; the connect history adds up to ~11 MiB at its setting's +/// ceiling (~6 MiB at the default; tests/conn_cost.rs), and the lobby maps, the rate map and the +/// list cache an estimated ~16 MiB: the master's worst case is ~71 MiB. pub const MAX_CONNS: usize = 512; /// Per source address. A browser open is three requests at once (lobbies, latest, thanks) and the /// list refreshes every 5 s (`server_browser_native.cpp:123`), so a household of four needs 12; diff --git a/server/tests/conn_cost.rs b/server/tests/conn_cost.rs index 1c4c998d1..0a4082901 100644 --- a/server/tests/conn_cost.rs +++ b/server/tests/conn_cost.rs @@ -146,6 +146,19 @@ fn connection_costs() { }); bound(&format!("connect history, {MANY} addresses, 20 connects each"), many, 1320.0); + // And at the setting's ceiling of 40 connects per address. + let history = ConnectHistory::new(40, Duration::from_secs(10), Duration::from_secs(60), 10_000); + let ((), many40) = held(|| { + for a in 0..MANY { + let ip = format!("10.{}.{}.{}", a >> 16, (a >> 8) & 255, a & 255); + for _ in 0..40 { + assert!(history.allow(&ip, now)); + } + } + }); + println!(" -> {:.0} B per address at 40 connects", many40 as f64 / MANY as f64); + bound(&format!("connect history, {MANY} addresses, 40 connects each"), many40, 2600.0); + // A rustls server connection holding one partial 16 KiB handshake record. let cfg = Arc::new(ServerConfig::builder().with_no_client_auth().with_cert_resolver(Arc::new(NoCert))); let (conn, new_cost) = held(|| ServerConnection::new(cfg.clone()).expect("a server connection")); From f4e1c0c8605cfc2e34588dc0abc2ab7178369942 Mon Sep 17 00:00:00 2001 From: pelmentor Date: Sun, 11 Oct 2026 01:35:43 +0600 Subject: [PATCH 131/306] [dev] a local switch runs the mod on the patron 0.10 build for the port The patron Shadow-Test-Build's mainGamemode BeginPlay scans Binaries for a dll and Content/Paks for a pak, then shows a modal and quits, so a UE4SS mod cannot run on it; the public build is expected to carry no such gate. coop/dev/patron_gate (off by default, never ships, edits no game file) Func-patches UFileSDKBPLibrary::GetFilesFromDirectory and empties its result only inside ExecuteUbergraph_mainGamemode -- the three scan sites, nothing else. Armed from boot before the first BeginPlay. Proven on the rig: the gate no longer fires, a fresh 0.10 world loads, and sdk-check resolves 123/125 against real 0.10 classes. Co-Authored-By: Claude Opus 4.8 --- .../coop/config/config_registry_rows_dev.inc | 6 ++ src/votv-coop/include/coop/dev/patron_gate.h | 20 +++++ src/votv-coop/sources_dev.cmake | 1 + src/votv-coop/src/bootstrap/boot.cpp | 5 ++ src/votv-coop/src/coop/dev/patron_gate.cpp | 79 +++++++++++++++++++ 5 files changed, 111 insertions(+) create mode 100644 src/votv-coop/include/coop/dev/patron_gate.h create mode 100644 src/votv-coop/src/coop/dev/patron_gate.cpp diff --git a/src/votv-coop/include/coop/config/config_registry_rows_dev.inc b/src/votv-coop/include/coop/config/config_registry_rows_dev.inc index 80419074f..e287f9d39 100644 --- a/src/votv-coop/include/coop/config/config_registry_rows_dev.inc +++ b/src/votv-coop/include/coop/config/config_registry_rows_dev.inc @@ -25,6 +25,12 @@ CFG_FLAG(freecam, "freecam", "dev", false, nullptr, "Enable the freecam toggle.") CFG_FLAG(spawn_menu_unlock, "spawn_menu_unlock", "dev", false, nullptr, "Unlock the native spawn menu while in a coop session.") +CFG_FLAG(patron_mod_gate_bypass, "patron_mod_gate_bypass", "dev", false, "VOTVCOOP_PATRON_MOD_GATE_BYPASS", + "LOCAL port-prep only: neutralise the VOTV 0.10 patron Shadow-Test-Build anti-mod gate (its " + "mainGamemode BeginPlay scans Binaries for *.dll and Content/Paks for *.pak, then pops 'Modded " + "client detected!' and quits). Empties that one scan's file list through our Func-patch substrate; " + "edits no game file. For running the mod on the unpacked patron build before the public 0.10; the " + "public build is expected to carry no such gate, so this is off by default and never ships.") CFG_FLAG(desk_diag, "desk_diag", "dev", false, "VOTVCOOP_DESK_DIAG", "Diagnostic (BOTH peers): periodically log the signal desk's state, and the digests a day " "rollover or a world load rolls per machine (the dish hash codes, the coordinate towers, the " diff --git a/src/votv-coop/include/coop/dev/patron_gate.h b/src/votv-coop/include/coop/dev/patron_gate.h new file mode 100644 index 000000000..428175647 --- /dev/null +++ b/src/votv-coop/include/coop/dev/patron_gate.h @@ -0,0 +1,20 @@ +// coop/dev/patron_gate.h -- [dev, LOCAL patron-build only] neutralise the Shadow-Test-Build anti-mod gate. +// +// The patron "Shadow Test Build" of VOTV 0.10 (NOT the public release) scans /Binaries for a +// .dll and Content/Paks for a .pak at the tail of mainGamemode::ReceiveBeginPlay, and on a match pops +// a modal "Modded client detected!" box and QuitGame, so a UE4SS mod cannot run on it; the public +// build is expected to carry no such gate. This instrument runs the mod LOCALLY on the unpacked +// patron build for the 0.10 port: OFF by default ([dev] patron_mod_gate_bypass), never shipped, no +// game-file edit (RULE 3 substrate; diagnostic/port tool, RULE 2 exempt). Mechanism in the .cpp. + +#pragma once + +namespace coop::dev::patron_gate { + +// Resolve UFileSDKBPLibrary::GetFilesFromDirectory and install the (idempotent) Func patch, gated by +// [dev] patron_mod_gate_bypass -- a single bool read when off, and a no-op once armed. Game thread; +// called from boot as soon as the game-thread context is live, which is well before the first +// gamemode BeginPlay. Safe to call repeatedly (a retry if the plugin class is not registered yet). +void EnsureArmed(); + +} // namespace coop::dev::patron_gate diff --git a/src/votv-coop/sources_dev.cmake b/src/votv-coop/sources_dev.cmake index b4bba4d99..0553c1157 100644 --- a/src/votv-coop/sources_dev.cmake +++ b/src/votv-coop/sources_dev.cmake @@ -76,6 +76,7 @@ set(VOTVCOOP_DEV_SOURCES src/coop/dev/grid_drill_upgrade.cpp src/coop/dev/tower_drill.cpp src/coop/dev/end_play_probe.cpp + src/coop/dev/patron_gate.cpp src/coop/dev/death_seam_census.cpp src/coop/dev/damage_probe.cpp src/coop/dev/grime_drill.cpp diff --git a/src/votv-coop/src/bootstrap/boot.cpp b/src/votv-coop/src/bootstrap/boot.cpp index 5b83340fe..a6cc914ee 100644 --- a/src/votv-coop/src/bootstrap/boot.cpp +++ b/src/votv-coop/src/bootstrap/boot.cpp @@ -7,6 +7,7 @@ #include "bootstrap/boot.h" #include "bootstrap/refuse_dialog.h" // the stand-down modal (never the overlay's dialog) +#include "coop/dev/patron_gate.h" // [dev] LOCAL: neutralise the patron Shadow-Test-Build mod gate #include "ui/native_text_field.h" // its un-gated editing selftest #include "coop/build_trust/build_trust.h" // ComputeSelf -- this DLL's hash and type #include "coop/net/protocol.h" // kProtocolVersion -- the b build rev in the banner @@ -222,6 +223,10 @@ DWORD WINAPI BootThread(LPVOID rawTag) { tid, bootTid, tid != bootTid ? "DIFFERENT thread -- OK" : "SAME -- WRONG", n); UE_LOGI("==== GAME-THREAD CONTEXT: LIVE ===="); + // [dev] LOCAL port-prep: if patron_mod_gate_bypass is set, neutralise the patron + // Shadow-Test-Build's anti-mod gate here, before the first gamemode BeginPlay runs its + // DLL/pak scan. A single bool read (and a no-op) when the flag is off; never ships. + coop::dev::patron_gate::EnsureArmed(); }); UE_LOGI("boot: game-thread dispatcher installed; self-test task posted"); // The script-body gate beside it: the second detour, on the VM's own body loop, so a diff --git a/src/votv-coop/src/coop/dev/patron_gate.cpp b/src/votv-coop/src/coop/dev/patron_gate.cpp new file mode 100644 index 000000000..c8f8c4f66 --- /dev/null +++ b/src/votv-coop/src/coop/dev/patron_gate.cpp @@ -0,0 +1,79 @@ +// coop/dev/patron_gate.cpp -- see the header. +#include "coop/dev/patron_gate.h" + +#include "coop/config/config.h" +#include "coop/config/config_registry.h" +#include "ue_wrap/core/log.h" +#include "ue_wrap/core/reflection.h" +#include "ue_wrap/core/sdk_profile.h" +#include "ue_wrap/core/ufunction_hook.h" + +#include + +namespace coop::dev::patron_gate { +namespace { + +namespace R = ue_wrap::reflection; +namespace P = ue_wrap::profile; +namespace UH = ue_wrap::ufunction_hook; + +void* g_fn = nullptr; // UFileSDKBPLibrary::GetFilesFromDirectory, once resolved +bool g_done = false; // armed, or given up for a named reason -- either way, stop trying +bool g_off = false, g_offRead = false; +int g_diagLines = 0; +constexpr int kMaxDiagLines = 6; + +// Post-hook on GetFilesFromDirectory: fires after the original filled the returned TArray. +// When the call came from ExecuteUbergraph_mainGamemode -- the sole ubergraph caller, and the +// anti-mod scan -- empty the list so the gate's loops see no file. The name is class-suffixed, so it +// uniquely identifies that function without an address compare (robust across a world reload). +// Game thread, deep in native dispatch: no allocation, no engine call, SEH-guarded by the facility. +void OnGetFilesFromDirectory(void* /*context*/, void* /*sourceObject*/, void* /*resultDeref*/) { + const UH::CallerFrame frame = UH::CurrentCallerFrame(); + if (!frame.function || !R::NameEquals(R::NameOf(frame.function), L"ExecuteUbergraph_mainGamemode")) + return; // a legitimate GetFilesFromDirectory (photos, printer meshes, textures) -- left alone + void* result = UH::CurrentResult(); // &ReturnValue, the TArray + if (!result) return; + int32_t& num = *reinterpret_cast(static_cast(result) + P::off::TArray_Num); + if (g_diagLines < kMaxDiagLines) { + ++g_diagLines; + UE_LOGW("patron_gate: mod scan in mainGamemode -- GetFilesFromDirectory returned %d file(s); " + "emptying the list so the Shadow-Test-Build mod gate finds nothing", num); + } + // ArrayNum = 0: every scan loop reads Length 0. The element buffers of this one call leak (a + // handful of FStrings, a few times per BeginPlay) -- acceptable for a local dev-only instrument. + num = 0; +} + +} // namespace + +void EnsureArmed() { + if (!g_offRead) { + g_off = !coop::config::ResolveFlag(::coop::config_registry::rows::patron_mod_gate_bypass); + g_offRead = true; + } + if (g_off || g_done) return; + + if (!g_fn) { + void* cls = R::FindClass(L"FileSDKBPLibrary"); + if (!cls) return; // the FileSDK plugin's class is not registered yet -- retried on a later call + g_fn = R::FindFunction(cls, L"GetFilesFromDirectory"); + if (!g_fn) { + UE_LOGE("patron_gate: FileSDKBPLibrary resolved but GetFilesFromDirectory did not -- the " + "Shadow-Test-Build mod gate CANNOT be neutralised (the dll/pak scan stands)"); + g_done = true; // the name is wrong for this build; do not spin + return; + } + } + if (UH::InstallPostHook(g_fn, &OnGetFilesFromDirectory)) { + g_done = true; + UE_LOGW("patron_gate: ARMED -- UFileSDKBPLibrary::GetFilesFromDirectory post-hook installed; the " + "patron Shadow-Test-Build 'Modded client detected!' gate is neutralised. LOCAL port-prep " + "instrument ([dev] patron_mod_gate_bypass); it never ships and edits no game file."); + } else { + UE_LOGE("patron_gate: the GetFilesFromDirectory Func patch did not install (fn=%p) -- gate stands", g_fn); + g_done = true; + } +} + +} // namespace coop::dev::patron_gate From 572a7f1329fa5fefa7695e9eff1246397c227fc8 Mon Sep 17 00:00:00 2001 From: pelmentor Date: Sun, 11 Oct 2026 02:37:34 +0600 Subject: [PATCH 132/306] [ue_wrap] a game function read as canonical text and fingerprinted script_fingerprint walks a UFunction's in-memory bytecode over the UE4.27 opcodes and writes it as lowercase, name-resolved text in flow order: labels for code offsets, a frame's slots renamed by first appearance, an event stub followed into its ubergraph, the signature first. FNV-1a 64 over the text is the fingerprint. The opcode table follows UAssetAPI's readers (MIT, noticed). [dev] fingerprint_capture writes every loaded Blueprint function with its text beside the exe once gameplay is up, the input of the agreement test against the port tooling's offline twin. Co-Authored-By: Claude Opus 5.5 --- THIRD-PARTY-NOTICES.md | 31 +- .../coop/config/config_registry_rows_dev.inc | 5 + .../include/harness/fingerprint_census.h | 18 + .../include/ue_wrap/core/script_fingerprint.h | 38 + .../include/ue_wrap/core/sdk_profile.h | 12 + src/votv-coop/sources.cmake | 2 + .../src/harness/fingerprint_census.cpp | 67 ++ src/votv-coop/src/harness/harness.cpp | 6 +- .../src/ue_wrap/core/script_fingerprint.cpp | 817 ++++++++++++++++++ 9 files changed, 994 insertions(+), 2 deletions(-) create mode 100644 src/votv-coop/include/harness/fingerprint_census.h create mode 100644 src/votv-coop/include/ue_wrap/core/script_fingerprint.h create mode 100644 src/votv-coop/src/harness/fingerprint_census.cpp create mode 100644 src/votv-coop/src/ue_wrap/core/script_fingerprint.cpp diff --git a/THIRD-PARTY-NOTICES.md b/THIRD-PARTY-NOTICES.md index fbfca4eed..87a11baa0 100644 --- a/THIRD-PARTY-NOTICES.md +++ b/THIRD-PARTY-NOTICES.md @@ -31,7 +31,8 @@ Portions of this software are copyright (c) 2006-2024 The FreeType Project Statically linked libraries: MinHook, Dear ImGui, GameNetworkingSockets (bundling Abseil, WebRTC, curve25519-donna, ed25519-donna), Opus, FreeType, -miniaudio, miniz, Protocol Buffers, OpenSSL, nlohmann/json. Ported code: RE-UE4SS, LuckPerms. +miniaudio, miniz, Protocol Buffers, OpenSSL, nlohmann/json. Ported code: RE-UE4SS, LuckPerms, +UAssetAPI. Embedded fonts: Roboto, JetBrains Mono, Cascadia Code, Fixedsys Excelsior, Twemoji Mozilla. @@ -1075,6 +1076,34 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` +## UAssetAPI (ported algorithm) + +No UAssetAPI code is linked; the operand layout of each UE4.27 Kismet bytecode instruction, as UAssetAPI's expression readers read it with their in-memory (iCode) accounting, was PORTED into the opcode table of `src/votv-coop/src/ue_wrap/core/script_fingerprint.cpp` with an attribution comment, and that portion carries the upstream MIT notice. Upstream: https://github.com/atenfyr/UAssetAPI (read from the fork https://github.com/Yangff/UAssetAPI). Source of this text: that fork's `LICENSE` at commit `0b4d2cff87bd391d56e9a208e35c92d77238bacb`, read from a local clone (the tree itself is not carried by this repository). + +```text +MIT License + +Copyright (c) 2023 Atenfyr + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +``` + --- The fonts below are embedded in `main.dll` as PE resources and merged into the diff --git a/src/votv-coop/include/coop/config/config_registry_rows_dev.inc b/src/votv-coop/include/coop/config/config_registry_rows_dev.inc index e287f9d39..502244b12 100644 --- a/src/votv-coop/include/coop/config/config_registry_rows_dev.inc +++ b/src/votv-coop/include/coop/config/config_registry_rows_dev.inc @@ -25,6 +25,11 @@ CFG_FLAG(freecam, "freecam", "dev", false, nullptr, "Enable the freecam toggle.") CFG_FLAG(spawn_menu_unlock, "spawn_menu_unlock", "dev", false, nullptr, "Unlock the native spawn menu while in a coop session.") +CFG_FLAG(fingerprint_capture, "fingerprint_capture", "dev", false, "VOTVCOOP_FINGERPRINT_CAPTURE", + "Port tool: once gameplay is loaded, write every function of every loaded Blueprint class with its " + "canonical bytecode text and fingerprint to multivoid-fingerprints-capture.txt beside the exe -- the " + "input of the agreement test against tools/port/bp_fingerprint.py and of a build's reference table " + "(docs/VERSION_PORT_DRILL_ARC.md section 7). A few seconds of work at world load; off by default.") CFG_FLAG(patron_mod_gate_bypass, "patron_mod_gate_bypass", "dev", false, "VOTVCOOP_PATRON_MOD_GATE_BYPASS", "LOCAL port-prep only: neutralise the VOTV 0.10 patron Shadow-Test-Build anti-mod gate (its " "mainGamemode BeginPlay scans Binaries for *.dll and Content/Paks for *.pak, then pops 'Modded " diff --git a/src/votv-coop/include/harness/fingerprint_census.h b/src/votv-coop/include/harness/fingerprint_census.h new file mode 100644 index 000000000..2dbaa0bbd --- /dev/null +++ b/src/votv-coop/include/harness/fingerprint_census.h @@ -0,0 +1,18 @@ +// harness/fingerprint_census.h -- the bytecode-fingerprint census of the functions the mod depends on +// (docs/VERSION_PORT_DRILL_ARC.md systems 2 and 3, VP-1). Runs once gameplay is loaded, beside the SDK +// check: every function the mod watches or hooks is fingerprinted on the running game and compared +// with the fingerprint of the build the mod was made for. +// +// [dev] fingerprint_capture writes every function of every loaded Blueprint class (and the census +// rows) with its canonical text to multivoid-fingerprints-capture.txt beside the exe: the input of the +// agreement test against tools/port/bp_fingerprint.py, and of the reference table a port generates. + +#pragma once + +namespace harness::fingerprint_census { + +// Game thread, after the world is up (posted with sdk_check::Run). A single flag read when the capture +// is off and no census row is due. +void Run(); + +} // namespace harness::fingerprint_census diff --git a/src/votv-coop/include/ue_wrap/core/script_fingerprint.h b/src/votv-coop/include/ue_wrap/core/script_fingerprint.h new file mode 100644 index 000000000..560b36203 --- /dev/null +++ b/src/votv-coop/include/ue_wrap/core/script_fingerprint.h @@ -0,0 +1,38 @@ +// ue_wrap/core/script_fingerprint.h -- a game function as canonical, name-resolved text and its 64-bit +// fingerprint, so a port can ask "is this the function the mod was built against?" (the version port +// arc, docs/VERSION_PORT_DRILL_ARC.md system 2; the canonical form is specified in its section 7). +// +// A Blueprint function is read from its in-memory bytecode (UStruct::Script) by a walker over every +// UE4.27 opcode, in the layout UAssetAPI (MIT) documents for the iCode stream: a pointer is 8 bytes, +// an FScriptName 12. Names, objects and properties are rendered as lowercase text, never as indices +// or addresses; statements are written in flow order with code offsets as labels; an event stub is +// followed into its ubergraph; a frame's own slots are renamed by first appearance. The offline tool +// tools/port/bp_fingerprint.py renders the same text from the cooked asset, and the two are held +// equal by a measured agreement test. A native function has no script: its text is the signature line. +// Engine-wrapper layer (principle 7): no gameplay or network logic. + +#pragma once + +#include +#include + +namespace ue_wrap::script_fingerprint { + +struct Result { + bool ok = false; // the whole script decoded and every code offset resolved + bool native = false; // a native function: the text is its signature line only + uint64_t hash = 0; // FNV-1a 64 over `text` + std::string text; // the canonical text, one statement per line + std::string problem; // the first problem met, empty when ok +}; + +// Fingerprint one UFunction. Game thread: names render through FName::ToString. Not hot -- a census +// pass at boot reads a few hundred functions once. A fault while reading is absorbed and reported in +// `problem`. +Result Of(void* ufunction); + +// The function's path as the canonical text writes objects: its outer chain without the package, +// lowercase, '.'-joined ("serverbox_c.fix", "actor.k2_destroyactor"). +std::string PathOf(void* uobject); + +} // namespace ue_wrap::script_fingerprint diff --git a/src/votv-coop/include/ue_wrap/core/sdk_profile.h b/src/votv-coop/include/ue_wrap/core/sdk_profile.h index 52586497c..033a942e5 100644 --- a/src/votv-coop/include/ue_wrap/core/sdk_profile.h +++ b/src/votv-coop/include/ue_wrap/core/sdk_profile.h @@ -290,6 +290,16 @@ inline constexpr size_t UWorld_FlagsB = 0x10D; // byte holding b inline constexpr uint8_t UWorld_bIsTearingDown = 0x20; // bit within FlagsB inline constexpr size_t UObject_GetWorld_VtblOff = 0x160; // UObject::GetWorld vtable byte offset +// FField::ClassPrivate (an FFieldClass*, its Name first) and Owner (FFieldVariant: the pointer, then a +// bool saying it is a UObject), and the type object a typed property carries first after FProperty +// (PropertyClass / Struct / Enum / SignatureFunction / an Array's Inner; an EnumProperty's Enum and a +// Map's value one slot later): RE-UE4SS MemberVariableLayout_4_27_Template.ini [FField] [FFieldClass] +// [FObjectPropertyBase] [FStructProperty] [FEnumProperty] [FMapProperty]. script_fingerprint reads them. +inline constexpr size_t FField_ClassPrivate = 0x08; // FFieldClass* +inline constexpr size_t FField_Owner = 0x10; // FField* or UObject* +inline constexpr size_t FField_OwnerIsUObject = 0x18; // bool +inline constexpr size_t FFieldClass_Name = 0x00; // FName +inline constexpr size_t FProperty_TypeObject = 0x78; // the property's type object (see above) inline constexpr size_t FField_Next = 0x20; // FField* inline constexpr size_t FField_NamePrivate = 0x28; // FName inline constexpr size_t FProperty_ElementSize = 0x38; // int32 @@ -545,9 +555,11 @@ inline constexpr size_t UsaveSlot_objectsData = 0x0300; // TArray< // The EPropertyFlags bits tested (engine-stable). namespace cpf { +inline constexpr uint64_t ConstParm = 0x2; inline constexpr uint64_t Parm = 0x80; inline constexpr uint64_t OutParm = 0x100; inline constexpr uint64_t ReturnParm = 0x400; +inline constexpr uint64_t ReferenceParm = 0x8000000; inline constexpr uint64_t InstancedReference = 0x80000; // a Blueprint's component variable carries it inline constexpr uint64_t NoDestructor = 0x1000000000; // the value needs no destroy (UFunction::DestructorLink skips it) } // namespace cpf diff --git a/src/votv-coop/sources.cmake b/src/votv-coop/sources.cmake index be3e5afe3..cd4d3889f 100644 --- a/src/votv-coop/sources.cmake +++ b/src/votv-coop/sources.cmake @@ -39,6 +39,7 @@ set(VOTVCOOP_SOURCES src/ue_wrap/engine/spawn_gate.cpp src/ue_wrap/engine/actor_end_play.cpp src/ue_wrap/core/ufunction_hook.cpp + src/ue_wrap/core/script_fingerprint.cpp src/ue_wrap/core/script_gate.cpp src/ue_wrap/core/script_loop.cpp src/ue_wrap/engine/engine.cpp @@ -645,6 +646,7 @@ set(VOTVCOOP_SOURCES src/ui/input_focus.cpp src/harness/screenshot.cpp src/harness/mod_environment.cpp + src/harness/fingerprint_census.cpp src/harness/sdk_check.cpp src/l10n/po_reader.cpp src/l10n/plural_expr.cpp diff --git a/src/votv-coop/src/harness/fingerprint_census.cpp b/src/votv-coop/src/harness/fingerprint_census.cpp new file mode 100644 index 000000000..14e97aae4 --- /dev/null +++ b/src/votv-coop/src/harness/fingerprint_census.cpp @@ -0,0 +1,67 @@ +// harness/fingerprint_census.cpp -- see harness/fingerprint_census.h. + +#include "harness/fingerprint_census.h" + +#include "coop/config/config.h" +#include "coop/config/config_registry.h" +#include "ue_wrap/core/log.h" +#include "ue_wrap/core/paths.h" +#include "ue_wrap/core/reflection.h" +#include "ue_wrap/core/script_fingerprint.h" +#include "ue_wrap/core/sdk_profile.h" + +#include +#include + +namespace harness::fingerprint_census { +namespace { + +namespace R = ue_wrap::reflection; +namespace P = ue_wrap::profile; +namespace FP = ue_wrap::script_fingerprint; + +// Every function of every loaded Blueprint class, with its canonical text: one pass over the object +// array, a few thousand functions, once per boot and only with the dev flag on. +void Capture() { + void* const bpClasses[] = {R::FindClass(L"BlueprintGeneratedClass"), R::FindClass(L"WidgetBlueprintGeneratedClass"), + R::FindClass(L"AnimBlueprintGeneratedClass")}; + void* const fnClasses[] = {R::FindClass(L"Function"), R::FindClass(L"DelegateFunction")}; + const std::wstring path = ue_wrap::paths::ExeDir() + L"\\multivoid-fingerprints-capture.txt"; + FILE* f = nullptr; + if (_wfopen_s(&f, path.c_str(), L"wb") != 0 || !f) { + UE_LOGE("fingerprint-census: cannot open '%ls' for the capture", path.c_str()); + return; + } + int classes = 0, functions = 0, ok = 0, problems = 0; + const int32_t n = R::NumObjects(); + for (int32_t i = 0; i < n; ++i) { + void* obj = R::ObjectAt(i); + if (!obj) continue; + void* cls = R::ClassOf(obj); + if (!cls || (cls != bpClasses[0] && cls != bpClasses[1] && cls != bpClasses[2])) continue; + ++classes; + void* field = *reinterpret_cast(static_cast(obj) + P::off::UStruct_Children); + for (int guard = 0; field && guard < 4096; + ++guard, field = *reinterpret_cast(static_cast(field) + P::off::UField_Next)) { + void* fc = R::ClassOf(field); + if (fc != fnClasses[0] && fc != fnClasses[1]) continue; + const FP::Result r = FP::Of(field); + ++functions; + if (r.ok) ++ok; else ++problems; + std::fprintf(f, "### %s %016llx %s\n", FP::PathOf(field).c_str(), static_cast(r.hash), + r.ok ? (r.native ? "native" : "ok") : r.problem.c_str()); + std::fwrite(r.text.data(), 1, r.text.size(), f); + } + } + std::fclose(f); + UE_LOGW("fingerprint-census: CAPTURE %d function(s) of %d loaded Blueprint class(es), %d decoded, %d with a " + "problem -> '%ls'", functions, classes, ok, problems, path.c_str()); +} + +} // namespace + +void Run() { + if (coop::config::ResolveFlag(::coop::config_registry::rows::fingerprint_capture)) Capture(); +} + +} // namespace harness::fingerprint_census diff --git a/src/votv-coop/src/harness/harness.cpp b/src/votv-coop/src/harness/harness.cpp index 84da0bf5d..cbde6be66 100644 --- a/src/votv-coop/src/harness/harness.cpp +++ b/src/votv-coop/src/harness/harness.cpp @@ -17,6 +17,7 @@ #include "harness/harness_diag.h" #include "harness/screenshot.h" #include "harness/mod_environment.h" +#include "harness/fingerprint_census.h" #include "harness/sdk_check.h" #include "coop/dev/freecam.h" #include "coop/dev/log_drill.h" // [dev] the two-process log drill @@ -285,7 +286,10 @@ DWORD WINAPI TimelineThread(LPVOID param) { // The SDK profile is checked against the running build (after the world boot on a host; on // a save-transfer client the classes load with the menu world, and every consumer // self-retries). - Post([] { harness::sdk_check::Run(); }); + Post([] { + harness::sdk_check::Run(); + harness::fingerprint_census::Run(); + }); // With a configured net role the puppet is network-driven and our pose is sent; otherwise // the puppet is a static local one. if (netEnabled) { diff --git a/src/votv-coop/src/ue_wrap/core/script_fingerprint.cpp b/src/votv-coop/src/ue_wrap/core/script_fingerprint.cpp new file mode 100644 index 000000000..4d962cd90 --- /dev/null +++ b/src/votv-coop/src/ue_wrap/core/script_fingerprint.cpp @@ -0,0 +1,817 @@ +// ue_wrap/core/script_fingerprint.cpp -- the bytecode walker, the flow order and the canonical text. The +// spec is docs/VERSION_PORT_DRILL_ARC.md section 7; tools/port/bp_fingerprint.py is the offline twin and +// every rule here has its line there. The opcode operand table follows UAssetAPI's Kismet expression +// readers (MIT, Atenfyr; THIRD-PARTY-NOTICES.md), whose iCode accounting is the in-memory layout. + +#include "ue_wrap/core/script_fingerprint.h" + +#include "ue_wrap/core/reflection.h" +#include "ue_wrap/core/sdk_profile.h" + +#include + +#include +#include +#include +#include +#include + +namespace ue_wrap::script_fingerprint { +namespace R = ue_wrap::reflection; +namespace P = ue_wrap::profile; + +namespace { + +// Operand kinds, in bp_fingerprint.py's letters: e L o p n i I x b h B l u f d s w c. +enum Kind : uint8_t { + kNone = 0, kExpr, kList, kObj, kProp, kName, kI32, kCode, kSkip, kU8, kU16, kBool, kI64, kU64, kF32, kF64, + kStr, kWStr, kCount, +}; +enum Special : uint8_t { sTable = 0, sText, sSwitch, sCast, sFloats3, sTransform }; + +struct Op { + const char* name = nullptr; // without EX_; null = not an opcode + Kind ops[4] = {}; + Special special = sTable; + uint8_t listEnd = 0; // the token a kList operand ends on +}; + +constexpr uint8_t EX_Jump = 0x06, EX_Return = 0x04, EX_IntConst = 0x1D, EX_FinalFunction = 0x1C, + EX_LocalFinalFunction = 0x46, EX_PopExecutionFlow = 0x4D, EX_ComputedJump = 0x4E, + EX_EndOfScript = 0x53; + +const Op* Table() { + static Op t[256]; + static bool built = false; + if (built) return t; + auto set = [](uint8_t code, const char* name, std::initializer_list ops, Special sp = sTable, + uint8_t end = 0) { + Op& o = t[code]; + o.name = name; + int i = 0; + for (Kind k : ops) o.ops[i++] = k; + o.special = sp; + o.listEnd = end; + }; + constexpr uint8_t kParms = 0x16; + set(0x00, "LocalVariable", {kProp}); + set(0x01, "InstanceVariable", {kProp}); + set(0x02, "DefaultVariable", {kProp}); + set(0x04, "Return", {kExpr}); + set(0x06, "Jump", {kCode}); + set(0x07, "JumpIfNot", {kCode, kExpr}); + set(0x09, "Assert", {kU16, kBool, kExpr}); + set(0x0B, "Nothing", {}); + set(0x0F, "Let", {kProp, kExpr, kExpr}); + set(0x12, "ClassContext", {kExpr, kSkip, kProp, kExpr}); + set(0x13, "MetaCast", {kObj, kExpr}); + set(0x14, "LetBool", {kExpr, kExpr}); + set(0x15, "EndParmValue", {}); + set(0x16, "EndFunctionParms", {}); + set(0x17, "Self", {}); + set(0x18, "Skip", {kSkip, kExpr}); + set(0x19, "Context", {kExpr, kSkip, kProp, kExpr}); + set(0x1A, "Context_FailSilent", {kExpr, kSkip, kProp, kExpr}); + set(0x1B, "VirtualFunction", {kName, kList}, sTable, kParms); + set(0x1C, "FinalFunction", {kObj, kList}, sTable, kParms); + set(0x1D, "IntConst", {kI32}); + set(0x1E, "FloatConst", {kF32}); + set(0x1F, "StringConst", {kStr}); + set(0x20, "ObjectConst", {kObj}); + set(0x21, "NameConst", {kName}); + set(0x22, "RotationConst", {}, sFloats3); + set(0x23, "VectorConst", {}, sFloats3); + set(0x24, "ByteConst", {kU8}); + set(0x25, "IntZero", {}); + set(0x26, "IntOne", {}); + set(0x27, "True", {}); + set(0x28, "False", {}); + set(0x29, "TextConst", {}, sText); + set(0x2A, "NoObject", {}); + set(0x2B, "TransformConst", {}, sTransform); + set(0x2C, "IntConstByte", {kU8}); + set(0x2D, "NoInterface", {}); + set(0x2E, "DynamicCast", {kObj, kExpr}); + set(0x2F, "StructConst", {kObj, kI32, kList}, sTable, 0x30); + set(0x30, "EndStructConst", {}); + set(0x31, "SetArray", {kExpr, kList}, sTable, 0x32); + set(0x32, "EndArray", {}); + set(0x33, "PropertyConst", {kProp}); + set(0x34, "UnicodeStringConst", {kWStr}); + set(0x35, "Int64Const", {kI64}); + set(0x36, "UInt64Const", {kU64}); + set(0x37, "DoubleConst", {kF64}); + set(0x38, "PrimitiveCast", {}, sCast); + set(0x39, "SetSet", {kExpr, kCount, kList}, sTable, 0x3A); + set(0x3A, "EndSet", {}); + set(0x3B, "SetMap", {kExpr, kCount, kList}, sTable, 0x3C); + set(0x3C, "EndMap", {}); + set(0x3D, "SetConst", {kProp, kCount, kList}, sTable, 0x3E); + set(0x3E, "EndSetConst", {}); + set(0x3F, "MapConst", {kProp, kProp, kCount, kList}, sTable, 0x40); + set(0x40, "EndMapConst", {}); + set(0x42, "StructMemberContext", {kProp, kExpr}); + set(0x43, "LetMulticastDelegate", {kExpr, kExpr}); + set(0x44, "LetDelegate", {kExpr, kExpr}); + set(0x45, "LocalVirtualFunction", {kName, kList}, sTable, kParms); + set(0x46, "LocalFinalFunction", {kObj, kList}, sTable, kParms); + set(0x48, "LocalOutVariable", {kProp}); + set(0x4A, "DeprecatedOp4A", {}); + set(0x4B, "InstanceDelegate", {kName}); + set(0x4C, "PushExecutionFlow", {kCode}); + set(0x4D, "PopExecutionFlow", {}); + set(0x4E, "ComputedJump", {kExpr}); + set(0x4F, "PopExecutionFlowIfNot", {kExpr}); + set(0x50, "Breakpoint", {}); + set(0x51, "InterfaceContext", {kExpr}); + set(0x52, "ObjToInterfaceCast", {kObj, kExpr}); + set(0x53, "EndOfScript", {}); + set(0x54, "CrossInterfaceCast", {kObj, kExpr}); + set(0x55, "InterfaceToObjCast", {kObj, kExpr}); + set(0x5A, "WireTracepoint", {}); + set(0x5B, "SkipOffsetConst", {kCode}); + set(0x5C, "AddMulticastDelegate", {kExpr, kExpr}); + set(0x5D, "ClearMulticastDelegate", {kExpr}); + set(0x5E, "Tracepoint", {}); + set(0x5F, "LetObj", {kExpr, kExpr}); + set(0x60, "LetWeakObjPtr", {kExpr, kExpr}); + set(0x61, "BindDelegate", {kName, kExpr, kExpr}); + set(0x62, "RemoveMulticastDelegate", {kExpr, kExpr}); + set(0x63, "CallMulticastDelegate", {kObj, kExpr, kList}, sTable, kParms); + set(0x64, "LetValueOnPersistentFrame", {kProp, kExpr}); + set(0x65, "ArrayConst", {kProp, kCount, kList}, sTable, 0x66); + set(0x66, "EndArrayConst", {}); + set(0x67, "SoftObjectConst", {kExpr}); + set(0x68, "CallMath", {kObj, kList}, sTable, kParms); + set(0x69, "SwitchValue", {}, sSwitch); + set(0x6B, "ArrayGetByRef", {kExpr, kExpr}); + set(0x6C, "ClassSparseDataVariable", {kProp}); + set(0x6D, "FieldPathConst", {kExpr}); + built = true; + return t; +} + +// ---- text helpers (the spec's folding and lowercasing) ---- + +void AppendFolded(std::string& out, uint32_t unit, bool lower) { + if (unit == '"' || unit == '\\') { + out += '\\'; + out += static_cast(unit); + } else if (unit >= 0x20 && unit <= 0x7E) { + char c = static_cast(unit); + if (lower && c >= 'A' && c <= 'Z') c = static_cast(c - 'A' + 'a'); + out += c; + } else { + out += '?'; + } +} + +std::string FoldLower(const std::wstring& s) { + std::string out; + out.reserve(s.size()); + for (wchar_t ch : s) AppendFolded(out, static_cast(ch), true); + return out; +} + +// A name's text, lowercase, with its number as the engine renders it ("foo_3" for number 4). Rendering +// costs an engine alloc and free (reflection.cpp), so each comparison index renders once per pass. +struct NameCache { + std::unordered_map base; + std::string Of(int32_t comparisonIndex, uint32_t number) { + auto it = base.find(comparisonIndex); + if (it == base.end()) { + R::FName n{comparisonIndex, 0}; + it = base.emplace(comparisonIndex, FoldLower(R::ToString(n))).first; + } + if (number == 0) return it->second; + return it->second + "_" + std::to_string(number - 1); + } +}; + +std::string StripNumber(const std::string& name) { + std::string out; + size_t start = 0; + bool first = true; + while (start <= name.size()) { + size_t end = name.find('_', start); + if (end == std::string::npos) end = name.size(); + std::string part = name.substr(start, end - start); + bool digits = !part.empty() && std::all_of(part.begin(), part.end(), [](char c) { return c >= '0' && c <= '9'; }); + if (first || !digits) { + if (!first) out += '_'; + out += part; + } + first = false; + start = end + 1; + } + return out; +} + +uint64_t Fnv1a64(const std::string& s) { + uint64_t h = 0xcbf29ce484222325ull; + for (unsigned char c : s) { + h ^= c; + h *= 0x100000001b3ull; + } + return h; +} + +template +T Rd(const void* base, size_t off) { + T v; + std::memcpy(&v, static_cast(base) + off, sizeof(T)); + return v; +} + +// ---- the context of one fingerprint ---- + +struct Stmt { + uint32_t off = 0, end = 0; + uint8_t op = 0; + std::vector targets; // code offsets inside, in operand order + int32_t uberEntry = -1; // >= 0: a call into the ubergraph at this entry + void* uberFn = nullptr; +}; + +struct Ctx { + NameCache names; + std::unordered_map locals; // a frame's own property -> its first-appearance index + std::string problem; + void Fail(const char* what, size_t at) { + if (!problem.empty()) return; + char b[96]; + std::snprintf(b, sizeof b, "%s at %zu", what, at); + problem = b; + } +}; + +std::string ObjPath(Ctx& c, void* obj); + +bool IsFunctionClass(Ctx& c, void* obj) { + void* cls = R::ClassOf(obj); + if (!cls) return false; + const R::FName& n = R::NameOf(cls); + const std::string s = c.names.Of(n.ComparisonIndex, static_cast(n.Number)); + return s == "function" || s == "delegatefunction" || s == "sparsedelegatefunction"; +} + +std::string ObjPath(Ctx& c, void* obj) { + std::vector parts; + for (int guard = 0; obj && R::OuterOf(obj) && guard < 64; ++guard) { + const R::FName& n = R::NameOf(obj); + parts.push_back(c.names.Of(n.ComparisonIndex, static_cast(n.Number))); + obj = R::OuterOf(obj); + } + std::string out; + for (size_t i = parts.size(); i-- > 0;) { + out += parts[i]; + if (i) out += '.'; + } + return out; +} + +std::string FieldName(Ctx& c, const void* field) { + const R::FName n = Rd(field, P::off::FField_NamePrivate); + return c.names.Of(n.ComparisonIndex, static_cast(n.Number)); +} + +std::string PropText(Ctx& c, void* field) { + if (!field) return "p:-"; + std::vector chain; + void* f = field; + void* ownerObj = nullptr; + for (int guard = 0; f && guard < 16; ++guard) { + chain.push_back(FieldName(c, f)); + void* owner = Rd(f, P::off::FField_Owner); + if (Rd(f, P::off::FField_OwnerIsUObject)) { + ownerObj = owner; + break; + } + f = owner; + } + std::string path = "p:" + (ownerObj ? ObjPath(c, ownerObj) : std::string()) + ":"; + for (size_t i = chain.size(); i-- > 0;) { + path += chain[i]; + if (i) path += '.'; + } + if (ownerObj && IsFunctionClass(c, ownerObj)) { + const int k = c.locals.emplace(field, static_cast(c.locals.size())).first->second; + return "l" + std::to_string(k) + ":" + StripNumber(chain.front()); + } + return path; +} + +// ---- the walker: one expression, scanned (targets only) or rendered ---- + +struct Walker { + Ctx& c; + const uint8_t* s; + size_t n; + size_t pos = 0; + bool render = false; + std::string* out = nullptr; + std::vector* targets = nullptr; + // label of a code offset (render mode); null = scan mode + const std::unordered_map* labels = nullptr; + uint32_t (*resolve)(const void*, uint32_t) = nullptr; + const void* resolveCtx = nullptr; + + bool Need(size_t k) { + if (pos + k > n) { + c.Fail("script ends inside an expression", pos); + return false; + } + return true; + } + template + bool Take(T& v) { + if (!Need(sizeof(T))) return false; + std::memcpy(&v, s + pos, sizeof(T)); + pos += sizeof(T); + return true; + } + void Emit(const std::string& t) { + if (render) *out += t; + } + void Emit(const char* t) { + if (render) *out += t; + } + void Hex32(const char* pre, uint32_t v) { + if (!render) return; + char b[24]; + std::snprintf(b, sizeof b, "%s%08x", pre, v); + *out += b; + } + + bool Operand(Kind k, uint8_t listEnd, bool& wrote) { + auto sep = [&]() { + if (wrote) Emit(","); + wrote = true; + }; + switch (k) { + case kExpr: sep(); return Expr(); + case kList: { + sep(); + Emit("["); + for (bool firstItem = true;;) { + if (!Need(1)) return false; + if (s[pos] == listEnd) { + ++pos; + break; + } + if (!firstItem) Emit(","); + firstItem = false; + if (!Expr()) return false; + } + Emit("]"); + return true; + } + case kObj: { + void* o = nullptr; + if (!Take(o)) return false; + sep(); + if (render) Emit(o ? "o:" + ObjPath(c, o) : std::string("o:-")); + return true; + } + case kProp: { + void* f = nullptr; + if (!Take(f)) return false; + sep(); + if (render) Emit(PropText(c, f)); + return true; + } + case kName: { + int32_t ci = 0, di = 0; + uint32_t num = 0; + if (!Take(ci) || !Take(di) || !Take(num)) return false; + sep(); + if (render) Emit("n:" + c.names.Of(ci, num)); + return true; + } + case kI32: { int32_t v; if (!Take(v)) return false; sep(); if (render) Emit("i:" + std::to_string(v)); return true; } + case kU8: { uint8_t v; if (!Take(v)) return false; sep(); if (render) Emit("b:" + std::to_string(v)); return true; } + case kU16: { uint16_t v; if (!Take(v)) return false; sep(); if (render) Emit("h:" + std::to_string(v)); return true; } + case kBool: { uint8_t v; if (!Take(v)) return false; sep(); Emit(v ? "B:1" : "B:0"); return true; } + case kI64: { int64_t v; if (!Take(v)) return false; sep(); if (render) Emit("l:" + std::to_string(v)); return true; } + case kU64: { uint64_t v; if (!Take(v)) return false; sep(); if (render) Emit("u:" + std::to_string(v)); return true; } + case kF32: { uint32_t v; if (!Take(v)) return false; sep(); Hex32("f:", v); return true; } + case kF64: { + uint64_t v; + if (!Take(v)) return false; + sep(); + if (render) { + char b[32]; + std::snprintf(b, sizeof b, "d:%016llx", static_cast(v)); + Emit(b); + } + return true; + } + case kCode: { + uint32_t v; + if (!Take(v)) return false; + sep(); + if (targets) targets->push_back(v); + if (render) { + const uint32_t r = resolve ? resolve(resolveCtx, v) : v; + const auto it = labels ? labels->find(r) : decltype(labels->find(r)){}; + Emit(labels && it != labels->end() ? "@" + std::to_string(it->second) : std::string("@?")); + } + return true; + } + case kSkip: + case kCount: { uint32_t v; return Take(v); } // derived from the layout: dropped + case kStr: { + sep(); + Emit("s:\""); + for (;;) { + uint8_t ch; + if (!Take(ch)) return false; + if (!ch) break; + if (render) AppendFolded(*out, ch, false); + } + Emit("\""); + return true; + } + case kWStr: { + sep(); + Emit("s:\""); + for (;;) { + uint16_t ch; + if (!Take(ch)) return false; + if (!ch) break; + if (render) AppendFolded(*out, ch, false); + } + Emit("\""); + return true; + } + default: return true; + } + } + + bool Expr() { + if (!Need(1)) return false; + const size_t at = pos; + const uint8_t code = s[pos++]; + const Op& op = Table()[code]; + if (!op.name) { + c.Fail("unknown opcode", at); + return false; + } + Emit(op.name); + switch (op.special) { + case sFloats3: + case sTransform: { + const int count = op.special == sTransform ? 10 : 3; + Emit("("); + for (int i = 0; i < count; ++i) { + uint32_t v; + if (!Take(v)) return false; + if (i) Emit(","); + Hex32("f:", v); + } + Emit(")"); + return true; + } + case sText: { + uint8_t kind; + if (!Take(kind)) return false; + static const char* kKinds[] = {"Empty", "LocalizedText", "InvariantText", "LiteralString", "StringTableEntry"}; + if (kind > 4) { + c.Fail("unknown text literal type", pos - 1); + return false; + } + Emit("(t:"); + Emit(kKinds[kind]); + const int subs = kind == 1 ? 3 : (kind == 2 || kind == 3) ? 1 : kind == 4 ? 2 : 0; + if (kind == 4) { + void* o = nullptr; + if (!Take(o)) return false; + if (render) Emit(o ? ",o:" + ObjPath(c, o) : std::string(",o:-")); + } + for (int i = 0; i < subs; ++i) { + Emit(","); + if (!Expr()) return false; + } + Emit(")"); + return true; + } + case sSwitch: { + uint16_t cases; + uint32_t endGoto; + if (!Take(cases) || !Take(endGoto)) return false; + Emit("("); + if (!Expr()) return false; + Emit(",["); + for (uint16_t i = 0; i < cases; ++i) { + if (i) Emit(","); + Emit("("); + if (!Expr()) return false; + uint32_t next; + if (!Take(next)) return false; + Emit(","); + if (!Expr()) return false; + Emit(")"); + } + Emit("],"); + if (!Expr()) return false; + Emit(")"); + return true; + } + case sCast: { + uint8_t ct; + if (!Take(ct)) return false; + const char* name = ct == 0x46 ? "ObjectToInterface" : ct == 0x47 ? "ObjectToBool" : ct == 0x49 ? "InterfaceToBool" : nullptr; + Emit("(k:"); + if (name) { + Emit(name); + } else if (render) { + Emit("?" + std::to_string(ct)); + } + if (ct == 0x46) { + void* o = nullptr; + if (!Take(o)) return false; + if (render) Emit(o ? ",o:" + ObjPath(c, o) : std::string(",o:-")); + } + Emit(","); + if (!Expr()) return false; + Emit(")"); + return true; + } + default: break; + } + if (op.ops[0] == kNone) return true; // no operand: the name alone + Emit("("); + bool wrote = false; + for (Kind k : op.ops) { + if (k == kNone) break; + if (!Operand(k, op.listEnd, wrote)) return false; + } + Emit(")"); + return true; + } +}; + +// ---- statements, flow order, rendering ---- + +bool IsUbergraph(Ctx& c, void* fn) { + if (!fn) return false; + const R::FName& n = R::NameOf(fn); + return c.names.Of(n.ComparisonIndex, 0).rfind("executeubergraph_", 0) == 0; +} + +bool Scan(Ctx& c, const uint8_t* s, size_t n, std::vector& out) { + size_t pos = 0; + while (pos < n) { + Stmt st; + st.off = static_cast(pos); + st.op = s[pos]; + Walker w{c, s, n}; + w.pos = pos; + w.targets = &st.targets; + if (!w.Expr()) return false; + st.end = static_cast(w.pos); + // an event stub's call into its ubergraph: (Local)FinalFunction(ExecuteUbergraph_*, IntConst(entry), ...) + if ((st.op == EX_LocalFinalFunction || st.op == EX_FinalFunction) && st.end - st.off >= 14) { + void* fn = Rd(s, pos + 1); + if (s[pos + 9] == EX_IntConst && IsUbergraph(c, fn)) { + st.uberFn = fn; + st.uberEntry = Rd(s, pos + 10); + } + } + pos = w.pos; + out.push_back(std::move(st)); + } + return true; +} + +struct Block { + const std::vector* stmts = nullptr; + std::unordered_map index; // offset -> statement index + uint32_t Resolve(uint32_t off) const { + for (size_t guard = 0; guard <= stmts->size(); ++guard) { + auto it = index.find(off); + if (it == index.end() || (*stmts)[it->second].op != EX_Jump) return off; + off = (*stmts)[it->second].targets.empty() ? off : (*stmts)[it->second].targets[0]; + } + return off; + } +}; + +uint32_t ResolveThunk(const void* blk, uint32_t off) { return static_cast(blk)->Resolve(off); } + +bool IsTerminator(uint8_t op) { + return op == EX_Jump || op == EX_Return || op == EX_EndOfScript || op == EX_PopExecutionFlow || op == EX_ComputedJump; +} + +// bp_fingerprint.py flow_order: follow a straight run (an EX_Jump is layout glue), defer every other +// code target LIFO in operand order, mark where a run merges into a labelled statement. +void RenderFlow(Ctx& c, const uint8_t* s, size_t n, const std::vector& stmts, uint32_t entry, + std::string& text) { + Block b; + b.stmts = &stmts; + for (size_t i = 0; i < stmts.size(); ++i) b.index[stmts[i].off] = static_cast(i); + std::vector order; + std::unordered_map label; + std::unordered_map merges; + std::vector pending{b.Resolve(entry)}; + while (!pending.empty()) { + uint32_t off = pending.back(); + pending.pop_back(); + if (label.count(off)) continue; + bool havePrev = false; + uint32_t prev = 0; + for (;;) { + auto it = b.index.find(off); + if (it == b.index.end()) { + c.Fail("code offset is not a statement", off); + break; + } + if (label.count(off)) { + if (havePrev) merges[prev] = off; + break; + } + label[off] = static_cast(order.size()); + order.push_back(off); + const Stmt& st = stmts[it->second]; + for (size_t k = st.targets.size(); k-- > 0;) pending.push_back(b.Resolve(st.targets[k])); + if (IsTerminator(st.op)) break; + const size_t i = static_cast(it->second); + if (i + 1 >= stmts.size()) break; + havePrev = true; + prev = off; + off = b.Resolve(stmts[i + 1].off); + } + } + for (uint32_t off : order) { + const Stmt& st = stmts[b.index[off]]; + text += std::to_string(label[off]) + " "; + Walker w{c, s, n}; + w.pos = off; + w.render = true; + w.out = &text; + w.labels = &label; + w.resolve = &ResolveThunk; + w.resolveCtx = &b; + if (st.uberEntry >= 0) { + // the stub's call: its entry offset is layout, the region appended after is the logic + text += Table()[st.op].name; + text += "(o:" + ObjPath(c, st.uberFn) + ",[IntConst(@U)"; + w.pos = off + 1 + 8 + 5; // past the opcode, the function pointer and IntConst(entry) + for (;;) { + if (w.pos >= n) break; + if (s[w.pos] == 0x16) break; + text += ","; + if (!w.Expr()) break; + } + text += "])"; + } else { + w.Expr(); + } + auto m = merges.find(off); + if (m != merges.end()) text += " >@" + std::to_string(label[m->second]); + text += "\n"; + } +} + +// ---- the signature line ---- + +std::string TypeText(Ctx& c, void* prop, int depth) { + void* fc = Rd(prop, P::off::FField_ClassPrivate); + std::string type; + if (fc) { + const R::FName n = Rd(fc, P::off::FFieldClass_Name); + type = c.names.Of(n.ComparisonIndex, static_cast(n.Number)); + } + if (depth > 4) return type; + auto obj = [&](size_t off) { + void* o = Rd(prop, off); + if (o) type += ":" + ObjPath(c, o); + }; + auto inner = [&](size_t off) { void* p = Rd(prop, off); return p ? TypeText(c, p, depth + 1) : std::string("-"); }; + if (type == "objectproperty" || type == "softobjectproperty" || type == "weakobjectproperty" || + type == "lazyobjectproperty" || type == "classproperty" || type == "softclassproperty" || + type == "interfaceproperty" || type == "structproperty" || type == "byteproperty" || + type == "delegateproperty" || type == "multicastdelegateproperty" || + type == "multicastinlinedelegateproperty" || type == "multicastsparsedelegateproperty") { + obj(P::off::FProperty_TypeObject); + } else if (type == "enumproperty") { + obj(P::off::FProperty_TypeObject + 8); + } else if (type == "arrayproperty" || type == "setproperty") { + type += "<" + inner(P::off::FProperty_TypeObject) + ">"; + } else if (type == "mapproperty") { + type += "<" + inner(P::off::FProperty_TypeObject) + "," + inner(P::off::FProperty_TypeObject + 8) + ">"; + } + return type; +} + +std::string SigLine(Ctx& c, void* fn) { + std::string out = "sig("; + bool first = true; + void* f = Rd(fn, P::off::UStruct_ChildProperties); + for (int guard = 0; f && guard < 512; ++guard, f = Rd(f, P::off::FField_Next)) { + const uint64_t flags = Rd(f, P::off::FProperty_PropertyFlags); + if (!(flags & P::cpf::Parm)) continue; + if (!first) out += ","; + first = false; + out += FieldName(c, f) + ":" + std::to_string(Rd(f, P::off::FProperty_ElementSize)) + ":"; + if (flags & P::cpf::ConstParm) out += 'C'; + if (flags & P::cpf::Parm) out += 'P'; + if (flags & P::cpf::OutParm) out += 'O'; + if (flags & P::cpf::ReturnParm) out += 'R'; + if (flags & P::cpf::ReferenceParm) out += 'F'; + out += ":" + TypeText(c, f, 0); + } + return out + ")\n"; +} + +// ---- one function ---- + +void Build(void* fn, Result& r, Ctx& c) { + const uint32_t fflags = Rd(fn, P::off::UFunction_FunctionFlags); + r.text = SigLine(c, fn); + if (fflags & P::off::FUNC_Native) { + r.native = true; + return; + } + const uint8_t* s = Rd(fn, P::off::UStruct_Script); + const int32_t n = Rd(fn, P::off::UStruct_ScriptNum); + if (!s || n <= 0) return; // a delegate signature or an empty function: its signature is all there is + std::vector stmts; + if (!Scan(c, s, static_cast(n), stmts)) return; + if (IsUbergraph(c, fn)) { + // the ubergraph itself: its dispatch is a computed jump, so its logic is the region of every event + // stub of the class that enters it, stub by stub in name order (a fresh frame map per entry) + std::vector> entries; + void* cls = R::OuterOf(fn); + void* f = cls ? Rd(cls, P::off::UStruct_Children) : nullptr; + for (int guard = 0; f && guard < 4096; ++guard, f = Rd(f, P::off::UField_Next)) { + const uint8_t* ss = Rd(f, P::off::UStruct_Script); + const int32_t sn = Rd(f, P::off::UStruct_ScriptNum); + if (!ss || sn <= 0 || f == fn) continue; + std::vector st; + Ctx probe; // a stub that does not decode is not an entry, and not this function's problem + if (!Scan(probe, ss, static_cast(sn), st)) continue; + for (const Stmt& x : st) + if (x.uberFn == fn) { + const R::FName& nm = R::NameOf(f); + entries.emplace_back(c.names.Of(nm.ComparisonIndex, static_cast(nm.Number)), x.uberEntry); + } + } + std::sort(entries.begin(), entries.end()); + for (const auto& e : entries) { + r.text += "== entry " + e.first + " ==\n"; + c.locals.clear(); + RenderFlow(c, s, static_cast(n), stmts, static_cast(e.second), r.text); + } + return; + } + RenderFlow(c, s, static_cast(n), stmts, 0, r.text); + for (const Stmt& st : stmts) { + if (st.uberEntry < 0) continue; + const uint8_t* us = Rd(st.uberFn, P::off::UStruct_Script); + const int32_t un = Rd(st.uberFn, P::off::UStruct_ScriptNum); + std::vector ust; + if (!us || un <= 0 || !Scan(c, us, static_cast(un), ust)) { + c.Fail("ubergraph does not decode", 0); + return; + } + r.text += "== " + ObjPath(c, st.uberFn) + " ==\n"; + RenderFlow(c, us, static_cast(un), ust, static_cast(st.uberEntry), r.text); + } +} + +int BuildSEH(void* fn, Result* r, Ctx* c) { + __try { + Build(fn, *r, *c); + return 0; + } __except (EXCEPTION_EXECUTE_HANDLER) { + return 1; + } +} + +} // namespace + +std::string PathOf(void* uobject) { + Ctx c; + return uobject ? ObjPath(c, uobject) : std::string(); +} + +Result Of(void* ufunction) { + Result r; + if (!ufunction) { + r.problem = "null function"; + return r; + } + Ctx c; + if (BuildSEH(ufunction, &r, &c)) { + r.problem = "fault while reading the function"; + r.text.clear(); + return r; + } + r.problem = c.problem; + r.ok = c.problem.empty(); + r.hash = Fnv1a64(r.text); + return r; +} + +} // namespace ue_wrap::script_fingerprint From f171ac60c65bfb0c2c13559a12b00692e0131879 Mon Sep 17 00:00:00 2001 From: pelmentor Date: Sun, 11 Oct 2026 02:44:56 +0600 Subject: [PATCH 133/306] [ue_wrap] FProperty's ArrayDim and ElementSize at their real offsets The profile named ElementSize at 0x38 and ArrayDim at 0x3C; RE-UE4SS's 4.27 layout has them the other way round. Every reader multiplied the two, so the product was right and the names were not. The fingerprint's signature line is the first reader of ElementSize alone: every parameter measured 1 byte (the array dimension) against the cooked asset's sizes in the agreement test. Co-Authored-By: Claude Opus 5.5 --- src/votv-coop/include/ue_wrap/core/sdk_profile.h | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/votv-coop/include/ue_wrap/core/sdk_profile.h b/src/votv-coop/include/ue_wrap/core/sdk_profile.h index 033a942e5..9bb3fe8db 100644 --- a/src/votv-coop/include/ue_wrap/core/sdk_profile.h +++ b/src/votv-coop/include/ue_wrap/core/sdk_profile.h @@ -302,8 +302,11 @@ inline constexpr size_t FFieldClass_Name = 0x00; // FName inline constexpr size_t FProperty_TypeObject = 0x78; // the property's type object (see above) inline constexpr size_t FField_Next = 0x20; // FField* inline constexpr size_t FField_NamePrivate = 0x28; // FName -inline constexpr size_t FProperty_ElementSize = 0x38; // int32 -inline constexpr size_t FProperty_ArrayDim = 0x3C; // int32 +// ArrayDim first, then ElementSize (RE-UE4SS MemberVariableLayout_4_27_Template.ini [FProperty]). The two +// were named the other way round until 2026-10-11; every reader multiplied them, so it never showed, until +// script_fingerprint read ElementSize alone and every parameter measured 1 byte. +inline constexpr size_t FProperty_ArrayDim = 0x38; // int32 +inline constexpr size_t FProperty_ElementSize = 0x3C; // int32 inline constexpr size_t FProperty_PropertyFlags = 0x40; // uint64 inline constexpr size_t FProperty_Offset_Internal = 0x4C; // int32 (byte offset in the frame) From 0f326494460298b6fcada1242827db3d938b162e Mon Sep 17 00:00:00 2001 From: pelmentor Date: Sun, 11 Oct 2026 02:49:12 +0600 Subject: [PATCH 134/306] [ue_wrap] the fingerprint's signature drops sizes and hashes apart A native struct's cooked ElementSize is not its run-time one: FHitResult is 144 bytes in the asset and 136 in the game, so 3,493 functions disagreed with the offline twin on that number alone. The type already names the struct. The signature line and the statements now also hash apart, so a census can tell wrong parameters from drifted logic. Co-Authored-By: Claude Opus 5.5 --- src/votv-coop/include/ue_wrap/core/script_fingerprint.h | 2 ++ src/votv-coop/src/ue_wrap/core/script_fingerprint.cpp | 7 ++++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/votv-coop/include/ue_wrap/core/script_fingerprint.h b/src/votv-coop/include/ue_wrap/core/script_fingerprint.h index 560b36203..dac500cd4 100644 --- a/src/votv-coop/include/ue_wrap/core/script_fingerprint.h +++ b/src/votv-coop/include/ue_wrap/core/script_fingerprint.h @@ -22,6 +22,8 @@ struct Result { bool ok = false; // the whole script decoded and every code offset resolved bool native = false; // a native function: the text is its signature line only uint64_t hash = 0; // FNV-1a 64 over `text` + uint64_t sigHash = 0; // over its first line, the signature: a change means wrong parameters + uint64_t bodyHash = 0; // over the rest, the statements: a change means the logic drifted std::string text; // the canonical text, one statement per line std::string problem; // the first problem met, empty when ok }; diff --git a/src/votv-coop/src/ue_wrap/core/script_fingerprint.cpp b/src/votv-coop/src/ue_wrap/core/script_fingerprint.cpp index 4d962cd90..5904b8aaf 100644 --- a/src/votv-coop/src/ue_wrap/core/script_fingerprint.cpp +++ b/src/votv-coop/src/ue_wrap/core/script_fingerprint.cpp @@ -713,7 +713,9 @@ std::string SigLine(Ctx& c, void* fn) { if (!(flags & P::cpf::Parm)) continue; if (!first) out += ","; first = false; - out += FieldName(c, f) + ":" + std::to_string(Rd(f, P::off::FProperty_ElementSize)) + ":"; + // no size: a native struct's cooked size is not its run-time one (FHitResult 144 in the asset, 136 + // in the game, measured by the agreement test), and the type already names the struct + out += FieldName(c, f) + ":"; if (flags & P::cpf::ConstParm) out += 'C'; if (flags & P::cpf::Parm) out += 'P'; if (flags & P::cpf::OutParm) out += 'O'; @@ -811,6 +813,9 @@ Result Of(void* ufunction) { r.problem = c.problem; r.ok = c.problem.empty(); r.hash = Fnv1a64(r.text); + const size_t sigEnd = r.text.find('\n'); + r.sigHash = Fnv1a64(r.text.substr(0, sigEnd == std::string::npos ? r.text.size() : sigEnd + 1)); + r.bodyHash = Fnv1a64(sigEnd == std::string::npos ? std::string() : r.text.substr(sigEnd + 1)); return r; } From b424704a22188f9fde645455305bf907f941e9c8 Mon Sep 17 00:00:00 2001 From: pelmentor Date: Sun, 11 Oct 2026 03:19:00 +0600 Subject: [PATCH 135/306] [ue_wrap] the decoder splits from the fingerprint; the spec is published script_bytecode holds the opcode table and the walker behind a private header; script_fingerprint keeps the flow order, the signature line and the hashes. The split is below the 800-line cap on both sides, and the capture it writes is unchanged. docs/bytecode-fingerprint.md is the canonical text's spec and what the agreement with the cooked assets measured; the code cites it, the page is allowlisted as public and the markdown count raised. Co-Authored-By: Claude Opus 5.5 --- .github/ci/public_prose_baseline.json | 2 +- .gitignore | 1 + docs/README.md | 1 + docs/bytecode-fingerprint.md | 107 ++++ .../coop/config/config_registry_rows_dev.inc | 6 +- .../include/harness/fingerprint_census.h | 12 +- .../include/ue_wrap/core/script_fingerprint.h | 26 +- .../include/ue_wrap/core/sdk_profile.h | 6 +- src/votv-coop/sources.cmake | 1 + .../src/ue_wrap/core/script_bytecode.cpp | 570 +++++++++++++++++ .../src/ue_wrap/core/script_bytecode.h | 74 +++ .../src/ue_wrap/core/script_fingerprint.cpp | 597 +----------------- 12 files changed, 792 insertions(+), 611 deletions(-) create mode 100644 docs/bytecode-fingerprint.md create mode 100644 src/votv-coop/src/ue_wrap/core/script_bytecode.cpp create mode 100644 src/votv-coop/src/ue_wrap/core/script_bytecode.h diff --git a/.github/ci/public_prose_baseline.json b/.github/ci/public_prose_baseline.json index 2ae9472ab..218fec919 100644 --- a/.github/ci/public_prose_baseline.json +++ b/.github/ci/public_prose_baseline.json @@ -2,7 +2,7 @@ "as_of": "860892917", "note": "measured on the working tree; as_of is HEAD at that moment", "counters": { - "md.files": 51, + "md.files": 52, "md.lines": 7696, "md.over_600": 0, "md.cyrillic": 1, diff --git a/.gitignore b/.gitignore index 2e04e310c..59594ce9b 100644 --- a/.gitignore +++ b/.gitignore @@ -241,6 +241,7 @@ docs/THUNDERSTORE.md # free and safe by default. The files that left the tree stay on disk; history is not rewritten. docs/** !docs/architecture.md +!docs/bytecode-fingerprint.md !docs/code-map.md !docs/coop-dispatch-visibility.md !docs/coop-entity-expression-map.md diff --git a/docs/README.md b/docs/README.md index f15a8306e..8952e2ecf 100644 --- a/docs/README.md +++ b/docs/README.md @@ -36,6 +36,7 @@ Then, if you are going to touch code: | [testing.md](testing.md) | The test rig, the launcher and its scenarios, the in-game harness, and the gates a push runs | | [release.md](release.md) | How a build becomes a release, and the gates it must pass | | [versioning.md](versioning.md) | The version pair two peers must match, the files a game update touches, how a broken build says so, and the port runbook | +| [bytecode-fingerprint.md](bytecode-fingerprint.md) | How the mod tells that a game function it depends on changed its logic: the canonical text of Blueprint bytecode and its fingerprints | | [votv-ui-style.md](votv-ui-style.md) | The game's own widget style, measured; binding for anything drawn inside the game's UI | Before writing any entity-sync, hook or spawn-catch code, read these three: diff --git a/docs/bytecode-fingerprint.md b/docs/bytecode-fingerprint.md new file mode 100644 index 000000000..934e287fd --- /dev/null +++ b/docs/bytecode-fingerprint.md @@ -0,0 +1,107 @@ +# Bytecode fingerprints + +## Purpose + +A game update can keep every class and function name the mod uses and still change what a function +does. Resolving names by string cannot see that. A fingerprint can: the mod renders a game function's +compiled Blueprint bytecode as canonical text and hashes it, so the same function on two builds of the +game compares equal when its logic is the same and differs when it is not. This page is the +specification of that text. The mod and the port tooling each render it, one from the running game and +one from the cooked assets, and the two renderings are compared function by function. + +## How it works + +### Reading the bytecode + +A Blueprint function's script is the byte array `UStruct::Script`. The mod walks it expression by +expression with a decoder of every UE4.27 Kismet opcode, laid out as the engine keeps it in memory: an +object or property pointer takes 8 bytes and a name (`FScriptName`) 12. The operand order of each +opcode follows UAssetAPI's expression readers (MIT). A decoder that mis-sized one operand would not land +on the script's end; on both game builds it has read every function of every loaded Blueprint class to +its end with no problem `[V]` (21,021 functions on 0.9.0n, 22,762 on 0.10.0 in a world). + +### The canonical text + +1. **Line 1 is the signature**: `sig(` then the function's parameters (the properties flagged + `CPF_Parm`) in declaration order, each `name:flags:type`, then `)`. The flags are letters in the + order C (const), P (parameter), O (out), R (return), F (reference). The type is the property class + (`objectproperty`), followed by `:` for an object, class, interface, struct, enum or + delegate property, `` for an array or set and `` for a map. There is no size: a + native struct's cooked size is not its run-time one (`FHitResult` reads 144 bytes in the asset and + 136 in the game `[V]`), and the type already names the struct. A native function has no script, so + its text is this line alone. +2. **Statements in flow order**, one per line as `