diff --git a/.github/ci/atlas_regime_gate.ps1 b/.github/ci/atlas_regime_gate.ps1
index db5644298..2cdc2c8b2 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,15 @@ 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 = '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' = {
diff --git a/.github/ci/data_paths_gate.py b/.github/ci/data_paths_gate.py
new file mode 100644
index 000000000..e3db7bf65
--- /dev/null
+++ b/.github/ci/data_paths_gate.py
@@ -0,0 +1,212 @@
+#!/usr/bin/env python3
+"""data_paths_gate -- the mod's own data is built from its own folder, never from the exe's.
+
+Everything the mod keeps for itself (the ini, the server profile, the player profiles, the seen list,
+the thanks cache, the identity key) lives in `ue_wrap::paths::ModDataDir()`, so a copied game folder
+carries none of it and a manager's reinstall starts clean. `ue_wrap::paths::ExeDir()` is the game
+exe's folder: it stays the home of the logs, the reports, the dumps and what a player authors (a
+translator's `.po` files). A data builder written against `ExeDir()` again would put a file back
+beside the exe, where a copy of the game folder carries it and a reinstall misses it. This gate holds,
+over `src/votv-coop/src` (comments and string literals set aside):
+
+ 1. a call of `ExeDir(` stands only in the files of EXE_DIR_FILES, each of which builds a file that
+ belongs beside the exe (a log, a report, a diagnostic, a rig signal, a translator's input, the
+ game's own files read);
+ 2. a `GetModuleFileName`, `W` or `A`, whose module argument is null (`nullptr`, `NULL`, `0`, or a
+ `GetModuleHandle` of null) -- the exe's PATH asked of Windows directly, the same folder by
+ another road -- stands only in the files of EXE_PATH_FILES, all four of which read only.
+ A `GetModuleHandle(nullptr)` used for the module's base is not a path and is not matched.
+
+The lists are keyed by file, not by line: a line moves with every edit. A file added to a list is a
+reviewed decision.
+
+ python .github/ci/data_paths_gate.py # PASS/FAIL over the tree
+ python .github/ci/data_paths_gate.py --tree
# the same over /src/votv-coop/src
+ python .github/ci/data_paths_gate.py --drill # the gate must fail each planted fault
+"""
+import pathlib
+import re
+import sys
+
+ROOT = pathlib.Path(__file__).resolve().parents[2]
+
+# What each file builds from ExeDir(): the reason it stays beside the exe.
+EXE_DIR_FILES = {
+ "ue_wrap/core/paths.cpp": "ExeDir itself, and ExistsBesideExe",
+ "bootstrap/boot.cpp": "multivoid-loaded.txt, the install marker",
+ "ue_wrap/core/log.cpp": "multivoid.log and multivoid.prev.log",
+ "harness/compat_report.cpp": "the compat report",
+ "coop/bug_report/report_files.cpp": "the bundle's entry for the compat report",
+ "coop/bug_report/report_bundle.cpp": "multivoid_reports",
+ "harness/fingerprint_capture.cpp": "the fingerprint capture, a dev diagnostic",
+ "harness/screenshot.cpp": "coop-screenshots, a dev diagnostic",
+ "coop/dev/dependency_dump.cpp": "the dev dump",
+ "coop/dev/rehost_rejoin.cpp": "the rejoin trigger, a rig signal",
+ "l10n/l10n.cpp": "multivoid_locale, a translator's own files, read only",
+ "coop/player/skin_registry.cpp": "Content/Paks/LogicMods, the game's pak tree, read only",
+ "harness/mod_environment.cpp": "Mods/mods.txt and LogicMods, a census of other mods, read only",
+ "ue_wrap/core/game_build.cpp": "the game's main pak, read only",
+}
+
+# The files that may ask Windows for the exe's path with a null module; all read only.
+EXE_PATH_FILES = {
+ "ue_wrap/core/paths.cpp": "ExeDir itself",
+ "loader/cppmod_entry.cpp": "the scan for an older mod DLL beside the exe",
+ "harness/sdk_check.cpp": "the compatibility report's exe line",
+ "ue_wrap/core/reflection.cpp": "LogGameVersion, the exe's file version",
+}
+
+EXE_DIR_CALL = re.compile(r"\bExeDir\s*\(")
+NULL_ARG = r"(?:nullptr|NULL|0)"
+EXE_PATH_CALL = re.compile(
+ r"\bGetModuleFileName[WA]?\s*\(\s*(?:" + NULL_ARG +
+ r"|(?:::\s*)?GetModuleHandle[WA]?\s*\(\s*" + NULL_ARG + r"\s*\))\s*,")
+SUFFIXES = (".cpp", ".h", ".hpp", ".inc")
+HEX_DIGITS = frozenset("0123456789abcdefABCDEF") # a set: the empty string at a text edge is not a member
+
+
+def number_run(text, i):
+ """Whether the word behind text[i] starts with a digit: a number (1, 700, 0xFF, 0xAB'CD'EF; the walk
+ crosses the earlier separators), where a quote is a separator; case'a' and u8'a' are words, and
+ their quote opens a character literal."""
+ j = i
+ while j > 0 and (text[j - 1].isalnum() or text[j - 1] in "_'"):
+ j -= 1
+ return text[j:j + 1].isdigit()
+
+
+def strip(text):
+ """The code of a C++ text, its comments and string and character literals blanked (newlines kept)."""
+ out, i, n = [], 0, len(text)
+ while i < n:
+ c = text[i]
+ two = text[i:i + 2]
+ if two == "//":
+ j = text.find("\n", i)
+ j = n if j < 0 else j
+ i = j
+ elif two == "/*":
+ j = text.find("*/", i + 2)
+ j = n if j < 0 else j + 2
+ out.append("\n" * text.count("\n", i, j))
+ i = j
+ elif c == "'" and text[i - 1:i] in HEX_DIGITS and text[i + 1:i + 2] in HEX_DIGITS and number_run(text, i):
+ out.append(c) # a C++14 digit separator (1'700'000'000), not the start of a character literal
+ i += 1
+ elif c == '"' or c == "'":
+ m = re.match(r'([^()\\ ]{0,16})\(', text[i + 1:i + 19]) if c == '"' and text[i - 1:i] == "R" else None
+ if m:
+ end = text.find(")" + m.group(1) + '"', i)
+ j = n if end < 0 else end + len(m.group(1)) + 2
+ else:
+ j = i + 1
+ while j < n and text[j] != c:
+ j += 2 if text[j] == "\\" else 1
+ j = min(n, j + 1)
+ out.append("\n" * text.count("\n", i, j))
+ out.append('""' if c == '"' else "''")
+ i = j
+ else:
+ out.append(c)
+ i += 1
+ return "".join(out)
+
+
+def check(files):
+ """files: {path relative to src/votv-coop/src, '/'-separated: text}. Returns the faults."""
+ faults = []
+ for path, text in sorted(files.items()):
+ code = strip(text)
+ for m in EXE_DIR_CALL.finditer(code):
+ if path not in EXE_DIR_FILES:
+ faults.append("%s:%d: ExeDir( builds a path from the exe's folder; the mod's own data goes "
+ "through ModDataDir (allowed files: data_paths_gate.EXE_DIR_FILES)"
+ % (path, code.count("\n", 0, m.start()) + 1))
+ for m in EXE_PATH_CALL.finditer(code):
+ if path not in EXE_PATH_FILES:
+ faults.append("%s:%d: GetModuleFileName of the null module is the exe's path by another "
+ "road (allowed files: data_paths_gate.EXE_PATH_FILES)"
+ % (path, code.count("\n", 0, m.start()) + 1))
+ return faults
+
+
+def tree_files(src):
+ out = {}
+ for p in sorted(src.rglob("*")):
+ if p.is_file() and p.suffix in SUFFIXES:
+ out[p.relative_to(src).as_posix()] = p.read_text(encoding="utf-8", errors="replace")
+ return out
+
+
+def drill():
+ allowed_dir = next(iter(EXE_DIR_FILES))
+ allowed_path = next(iter(EXE_PATH_FILES))
+ call = "std::wstring p = ue_wrap::paths::ExeDir() + L\"\\\\multivoid.ini\";\n"
+ cases = {
+ "ExeDir call in a data builder": {"coop/config/config.cpp": call},
+ "ExeDir call over two lines": {"coop/x/a.cpp": "auto d =\n paths::ExeDir\n ();\n"},
+ "ExeDir call in a header": {"coop/x/a.h": "inline auto D() { return paths::ExeDir(); }\n"},
+ "GetModuleFileNameW(nullptr": {"coop/x/a.cpp": "::GetModuleFileNameW(nullptr, buf, MAX_PATH);\n"},
+ "GetModuleFileNameA(NULL": {"coop/x/a.cpp": "GetModuleFileNameA(NULL, buf, n);\n"},
+ "GetModuleFileNameW(0": {"coop/x/a.cpp": "GetModuleFileNameW( 0 , buf, n);\n"},
+ "GetModuleFileName of GetModuleHandle(nullptr)": {
+ "coop/x/a.cpp": "::GetModuleFileNameW(::GetModuleHandleW(nullptr), exe, MAX_PATH);\n"},
+ "ExeDir after a digit separator": {
+ "coop/x/a.cpp": "constexpr long k = 1'700'000'000;\nauto d = paths::ExeDir();\n// it's\n"},
+ "ExeDir after a 3-group hex literal": {
+ "coop/x/a.cpp": "auto k = 0xAB'CD'EF; auto d = paths::ExeDir();\n"},
+ "ExeDir after a letter-led hex group": {
+ "coop/x/a.cpp": "auto k = 0xDEAD'BEEF'CAFE; auto d = paths::ExeDir();\n"},
+ "ExeDir after case'a'": {"coop/x/a.cpp": "switch (x) { case'a': auto d = paths::ExeDir(); }\n"},
+ "ExeDir after u8'a'": {"coop/x/a.cpp": "auto s = u8'a'; auto d = paths::ExeDir();\n"},
+ "GetModuleFileName(NULL, over two lines": {
+ "coop/x/a.cpp": "GetModuleFileNameW(\n NULL,\n buf, n);\n"},
+ }
+ clean = {
+ "an allowed file calls ExeDir": {allowed_dir: call},
+ "an allowed file asks the null module": {allowed_path: "GetModuleFileNameW(nullptr, p, n);\n"},
+ "ExeDir in a comment": {"coop/x/a.cpp": "// the old ExeDir() place\n/* ExeDir( */\n"},
+ "ExeDir in a string": {"coop/x/a.cpp": 'const char* s = "ExeDir()";\n'},
+ "a module variable": {"coop/x/a.cpp": "GetModuleFileNameW(mod, buf, n);\n"},
+ "the module's base": {"coop/x/a.cpp": "auto b = ::GetModuleHandleW(nullptr);\n"},
+ "GetModuleFileNameEx of a process": {
+ "coop/x/a.cpp": "K32GetModuleFileNameExW(proc, nullptr, path, MAX_PATH);\n"},
+ "a character literal beside a digit separator": {
+ "coop/x/a.cpp": "char c = 'a'; // ExeDir(\nint n = 0xFF'FF; char d = '\\'';\n"},
+ "ModDataDir": {"coop/config/config.cpp": "auto d = ue_wrap::paths::ModDataDir();\n"},
+ }
+ bad = 0
+ for name, files in cases.items():
+ if not check(files):
+ print("drill: the gate PASSED a planted fault: %s" % name)
+ bad += 1
+ for name, files in clean.items():
+ if check(files):
+ print("drill: the gate FAILED a clean file: %s" % name)
+ bad += 1
+ print("data_paths_gate drill: %s (%d planted fault(s), %d clean case(s))"
+ % ("PASS" if not bad else "FAIL", len(cases), len(clean)))
+ return 1 if bad else 0
+
+
+def main():
+ if "--drill" in sys.argv:
+ return drill()
+ base = ROOT
+ if "--tree" in sys.argv:
+ base = pathlib.Path(sys.argv[sys.argv.index("--tree") + 1])
+ src = base / "src" / "votv-coop" / "src"
+ if not src.is_dir():
+ print("data_paths_gate: FAIL (no source folder %s)" % src)
+ return 1
+ files = tree_files(src)
+ faults = check(files)
+ for f in faults:
+ print(f)
+ print("data_paths_gate: %s (%d file(s) read, %d fault(s))"
+ % ("PASS" if not faults else "FAIL", len(files), len(faults)))
+ return 1 if faults else 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/.github/ci/fact_rows_gate.py b/.github/ci/fact_rows_gate.py
new file mode 100644
index 000000000..63d665d9a
--- /dev/null
+++ b/.github/ci/fact_rows_gate.py
@@ -0,0 +1,143 @@
+#!/usr/bin/env python3
+"""fact_rows_gate -- every game-function row of ue_wrap/facts is well formed and has a reference.
+
+A row (`UE_FACT_FN(ident, "feature", L"class" | nullptr, L"function", Mode)`, declared in the file that
+registers it) is what the mod's fingerprint census checks against the reference table of the game
+target, `src/votv-coop/include/ue_wrap/facts/fingerprints_.inc` (docs/bytecode-fingerprint.md).
+This gate holds, over the mod's source:
+
+ 1. a row lives in a .cpp: in a header it would register once per translation unit;
+ 2. its class and function are string literals and its feature a lowercase id ([a-z0-9_.]): the
+ table, this gate and a reader all read them as written;
+ 3. its mode is one of Declared, Dispatch, AnyClass, RuntimeClass; an AnyClass row has no class, and a
+ Declared or Dispatch row has one (it is resolved on it, and with none would never install);
+ 4. its ident is used in its file beyond the declaration: a row nothing registers is a dead fact;
+ 5. the target's table has an entry for its key -- present or recorded absent for a class row, a
+ signature set for an any-class row, and for a run-time class row with no class an entry on at
+ least one class (the census keys it on the class its site resolves) -- so a release never ships a
+ row the census cannot judge.
+
+ python .github/ci/fact_rows_gate.py # PASS/FAIL over the tree
+ python .github/ci/fact_rows_gate.py --drill # the gate must fail each planted fault
+"""
+import pathlib
+import re
+import sys
+
+ROOT = pathlib.Path(__file__).resolve().parents[2]
+MOD = ROOT / "src" / "votv-coop"
+ROW = re.compile(r"UE_FACT_FN\s*\(([^;]*?)\)\s*;", re.S)
+ARGS = re.compile(r'^\s*(\w+)\s*,\s*"([^"]*)"\s*,\s*(nullptr|L"[^"]*")\s*,\s*(L"[^"]*")\s*,\s*(\w+)\s*$', re.S)
+MODES = {"Declared", "Dispatch", "AnyClass", "RuntimeClass"}
+TABLE_FN = re.compile(r'\{L"([^"]*)",\s*L"([^"]*)",')
+TABLE_ANY = re.compile(r'\{L"([^"]*)",\s*\d+,\s*\d+,\s*\d+\}')
+
+
+def target():
+ m = re.search(r'set\(VOTVCOOP_GAME_TARGET\s+"([^"]+)"\)', (MOD / "CMakeLists.txt").read_text(encoding="utf-8"))
+ return m.group(1) if m else None
+
+
+def check(files, table_text):
+ """files: {path: text}. Returns the list of faults."""
+ faults = []
+ fns = {(c.lower(), f.lower()) for c, f in TABLE_FN.findall(table_text or "")}
+ anys = {f.lower() for f in TABLE_ANY.findall(table_text or "")}
+ for path, text in sorted(files.items()):
+ if path.endswith(("function_row.h",)):
+ continue # the macro's own definition and its comment example
+ for m in ROW.finditer(text):
+ line = text.count("\n", 0, m.start()) + 1
+ where = "%s:%d" % (path, line)
+ a = ARGS.match(m.group(1))
+ if not path.endswith(".cpp"):
+ faults.append("%s: a row outside a .cpp" % where)
+ if not a:
+ faults.append("%s: a row whose arguments are not (ident, \"feature\", L\"class\"|nullptr, "
+ "L\"function\", Mode) literals" % where)
+ continue
+ ident, feature, cls, fn, mode = a.groups()
+ if not re.fullmatch(r"[a-z0-9_.]+", feature):
+ faults.append("%s: feature %r is not a lowercase id" % (where, feature))
+ if mode not in MODES:
+ faults.append("%s: mode %s is not one of %s" % (where, mode, sorted(MODES)))
+ if mode == "AnyClass" and cls != "nullptr":
+ faults.append("%s: an AnyClass row names a class" % where)
+ if mode in ("Declared", "Dispatch") and cls == "nullptr":
+ faults.append("%s: a %s row names no class" % (where, mode))
+ if len(re.findall(r"\b%s\b" % re.escape(ident), text)) < 2:
+ faults.append("%s: row %s is declared and never used" % (where, ident))
+ if table_text is None:
+ continue
+ fname = fn[2:-1].lower()
+ if mode == "AnyClass":
+ if fname not in anys:
+ faults.append("%s: no reference entry for any-class row %s" % (where, fname))
+ elif cls == "nullptr":
+ if fname not in {f for _, f in fns}:
+ faults.append("%s: no reference entry keys run-time class row %s on any class" % (where, fname))
+ elif (cls[2:-1].lower(), fname) not in fns:
+ faults.append("%s: no reference entry for %s.%s" % (where, cls[2:-1], fn[2:-1]))
+ return faults
+
+
+def tree_files():
+ out = {}
+ for p in list((MOD / "src").rglob("*")) + list((MOD / "include").rglob("*")):
+ if p.suffix in (".cpp", ".h", ".inc", ".hpp"):
+ text = p.read_text(encoding="utf-8", errors="replace")
+ if "UE_FACT_FN" in text:
+ out[str(p.relative_to(ROOT)).replace("\\", "/")] = text
+ return out
+
+
+def drill():
+ good_table = ('{L"a_c", L"f", L"a_c.f", 0x1ull, 0x2ull},\n{L"g", 3, 0, 1},\n')
+ base = 'UE_FACT_FN(kRowF, "lane", L"A_C", L"f", Declared);\nvoid X() { Use(kRowF); }\n'
+ cases = {
+ "header row": ({"x/a.h": base}, good_table),
+ "non-literal class": ({"x/a.cpp": base.replace('L"A_C"', "kClass")}, good_table),
+ "upper-case feature": ({"x/a.cpp": base.replace('"lane"', '"Lane"')}, good_table),
+ "unknown mode": ({"x/a.cpp": base.replace("Declared", "Exact")}, good_table),
+ "unused row": ({"x/a.cpp": base.replace("Use(kRowF);", "")}, good_table),
+ "no reference": ({"x/a.cpp": base.replace('L"f"', 'L"h"').replace("kRowF", "kRowH")}, good_table),
+ "any-class with a class": ({"x/a.cpp": 'UE_FACT_FN(kRowG, "lane", L"A_C", L"g", AnyClass);\n'
+ 'void X() { Use(kRowG); }\n'}, good_table),
+ "declared row with no class": ({"x/a.cpp": 'UE_FACT_FN(kRowD, "lane", nullptr, L"f", Declared);\n'
+ 'void X() { Use(kRowD); }\n'}, good_table),
+ "run-time class row keyed on no class": ({"x/a.cpp": 'UE_FACT_FN(kRowR, "lane", nullptr, L"r", RuntimeClass);\n'
+ 'void X() { Use(kRowR); }\n'}, good_table),
+ }
+ bad = 0
+ for name, (files, table) in cases.items():
+ if not check(files, table):
+ print("drill: the gate PASSED a planted fault: %s" % name)
+ bad += 1
+ clean = base + 'UE_FACT_FN(kRowR, "lane", nullptr, L"f", RuntimeClass);\nvoid Y() { Use(kRowR); }\n'
+ if check({"x/a.cpp": clean}, good_table):
+ print("drill: the gate FAILED a clean row")
+ bad += 1
+ print("fact_rows_gate drill: %s (%d planted fault(s))" % ("PASS" if not bad else "FAIL", len(cases)))
+ return 1 if bad else 0
+
+
+def main():
+ if "--drill" in sys.argv:
+ return drill()
+ t = target()
+ table = MOD / "include" / "ue_wrap" / "facts" / ("fingerprints_%s.inc" % t)
+ table_text = table.read_text(encoding="utf-8") if table.exists() else None
+ files = tree_files()
+ faults = check(files, table_text)
+ if table_text is None:
+ faults.append("no reference table %s for the game target %s" % (table.relative_to(ROOT), t))
+ rows = sum(len(ROW.findall(x)) for p, x in files.items() if not p.endswith("function_row.h"))
+ for f in faults:
+ print(f)
+ print("fact_rows_gate: %s (%d row(s) in %d file(s), reference %s)" % ("PASS" if not faults else "FAIL", rows,
+ len(files), t))
+ return 1 if faults else 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
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/l10n_pot.py b/.github/ci/l10n_pot.py
new file mode 100644
index 000000000..78c95aa07
--- /dev/null
+++ b/.github/ci/l10n_pot.py
@@ -0,0 +1,470 @@
+#!/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 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;
+ 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, 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.
+"""
+
+import pathlib
+import re
+import sys
+
+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
+# 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/props/prop_act_request.cpp",
+ "src/coop/props/remover_request.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/chat_input.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/game_build_dialog.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():
+ # 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))
+
+
+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)
+ # 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:
+ 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 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")
+ 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)
+ pseudo = render_pseudo(entries)
+ if "--check" in sys.argv[1:]:
+ 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):
+ 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")
+ 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
+
+
+if __name__ == "__main__":
+ sys.exit(main())
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/ci/ledger_lib.ps1 b/.github/ci/ledger_lib.ps1
index 085af5862..d41d67343 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)
@@ -134,6 +149,30 @@ function Get-NewestPublished {
$null
}
+# The newest PUBLISHED stable per game target -- Get-NewestPublished's fold, per game: a hashtable
+# game -> ledger state. The masters' COOP_LATEST_GAMES is this map (docs/release.md), so a player on one game
+# is told about the newest Multivoid for that game and not about one for another.
+function Get-NewestPublishedPerGame {
+ param([Parameter(Mandatory)][AllowEmptyCollection()][object[]]$Rows,
+ [switch]$IncludeDev)
+ $out = @{}
+ $cands = @($Rows | Where-Object { $_.Kind -eq 'published' -and ($IncludeDev -or -not $_.Dev) })
+ for ($i = $cands.Count - 1; $i -ge 0; $i--) {
+ if ($out.ContainsKey($cands[$i].Game)) { continue }
+ $st = Get-LedgerState -Rows $Rows -N $cands[$i].N
+ if ($st.State -eq 'PUBLISHED') { $out[$cands[$i].Game] = $st }
+ }
+ $out
+}
+
+# COOP_LATEST_GAMES's value for a map game -> anything with N: ":: b", comma-joined, oldest N
+# first -- the master's grammar (server/src/lobby.rs parse_latest_games).
+function Format-LatestGames {
+ param([Parameter(Mandatory)][hashtable]$PerGame)
+ (@($PerGame.GetEnumerator() | Sort-Object { [int]$_.Value.N }) |
+ ForEach-Object { "$($_.Key):$($_.Value.N):$($_.Key) b$($_.Value.N)" }) -join ','
+}
+
# --- Proto extraction ----------------------------------------------------
# kProtocolVersion at a given commit, read via `git show` (no checkout needed).
diff --git a/.github/ci/master_contact_gate.py b/.github/ci/master_contact_gate.py
index 6208c7af5..cba08c5c0 100644
--- a/.github/ci/master_contact_gate.py
+++ b/.github/ci/master_contact_gate.py
@@ -44,7 +44,10 @@
# is downstream of an action the player took; see b349de42's message for the
# lane-by-lane reasoning.
ALLOWED_HTTP = {
- "coop/net/lobby_announcer.cpp": {"/v1/host", "/v1/heartbeat", "/v1/visibility", "/v1/leave"},
+ # `/v1/host/challenge` is the nonce a create signs: the same host action as `/v1/host`, one call
+ # earlier, so it is sent only when the player hosts through a master.
+ "coop/net/lobby_announcer.cpp": {"/v1/host/challenge", "/v1/host", "/v1/heartbeat", "/v1/visibility",
+ "/v1/leave"},
# `` is the /v1/lobbies fetch: the path is assembled with query
# parameters, so it reaches http::Get as a variable rather than a literal.
# Declared explicitly so an unreadable endpoint still has to be admitted.
diff --git a/.github/ci/public_prose_baseline.json b/.github/ci/public_prose_baseline.json
index a702ffbbb..867fc2abd 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": 53,
"md.lines": 7696,
"md.over_600": 0,
"md.cyrillic": 1,
diff --git a/.github/ci/publish.ps1 b/.github/ci/publish.ps1
index ffe508754..709bd57b0 100644
--- a/.github/ci/publish.ps1
+++ b/.github/ci/publish.ps1
@@ -22,6 +22,18 @@ $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}\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:
+# a tag moved after the judge would publish these assets under another commit.
+function Assert-TagStillNamesJudgedSha {
+ $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"
+}
# --- 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 +128,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 +145,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
@@ -161,6 +175,10 @@ if ($tag.Dev) {
Write-Host "published: $title ($TagName @ $TagSha)"
Write-Host 'RITUAL REMINDER: verify the release page, then append the published row to .github/ci/LEDGER.tsv (closes state(N) API-free).'
if (-not $tag.Dev) {
- Write-Host 'STABLE: update EVERY master''s env constants, then run .github/ci/verify_latest.ps1:'
- Write-Host " COOP_LATEST_PROTO=$($tag.N) COOP_LATEST_MOD=`"$($tag.Game) b$($tag.N)`""
+ # Every game's newest published stable, this one included (its published row is appended after): the whole
+ # list each time, so a master's line is replaced, never edited.
+ $perGame = Get-NewestPublishedPerGame -Rows (Read-Ledger -Path (Join-Path $PSScriptRoot 'LEDGER.tsv')).Rows
+ $perGame[$tag.Game] = [pscustomobject]@{ N = $tag.N }
+ Write-Host 'STABLE: set EVERY master''s COOP_LATEST_GAMES to this line, then run .github/ci/verify_latest.ps1:'
+ Write-Host " COOP_LATEST_GAMES=`"$(Format-LatestGames -PerGame $perGame)`""
}
diff --git a/.github/ci/registry_gate.ps1 b/.github/ci/registry_gate.ps1
index ae3984b58..f13f07723 100644
--- a/.github/ci/registry_gate.ps1
+++ b/.github/ci/registry_gate.ps1
@@ -44,14 +44,22 @@ $ErrorActionPreference = 'Stop'
if (-not $Root) { $Root = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) }
$incPath = Join-Path $Root 'src\votv-coop\include\coop\config\config_registry_rows.inc'
-# The [dev] section's own file, which the row list includes at its end.
-$devIncPath = Join-Path $Root 'src\votv-coop\include\coop\config\config_registry_rows_dev.inc'
$cfgHdr = Join-Path $Root 'src\votv-coop\include\coop\config\config.h'
$srcDirs = @((Join-Path $Root 'src\votv-coop\src'), (Join-Path $Root 'src\votv-coop\include'))
-# The registry's own files self-reference every ident (decl/def/table, the
-# rows and their [dev] file) and must not satisfy the census.
-$selfFiles = @('config_registry.h', 'config_registry.cpp', 'config_registry_rows.inc',
- 'config_registry_rows_dev.inc')
+# The row files: the list and every row file it includes, recursively, as the compiler reads them (the [dev]
+# section's file, and the files that section is split into), so a split needs no change here.
+function Get-RowFiles([string]$path) {
+ $files = @($path)
+ foreach ($line in (Get-Content $path)) {
+ if ($line -match '^\s*#include\s+"coop/config/(?config_registry_rows[^"]*\.inc)"') {
+ $files += Get-RowFiles (Join-Path (Split-Path -Parent $path) $Matches['f'])
+ }
+ }
+ return $files
+}
+# The registry's own files self-reference every ident (decl/def/table, and the
+# row files, added as they are read) and must not satisfy the census.
+$selfFiles = @('config_registry.h', 'config_registry.cpp')
$violations = New-Object System.Collections.Generic.List[string]
$controlFailures = New-Object System.Collections.Generic.List[string]
@@ -72,8 +80,8 @@ $writeOnlyAllow = @{
if (-not (Test-Path $incPath)) { Write-Host "registry_gate: FAIL -- missing $incPath"; exit 1 }
$rowIdents = New-Object System.Collections.Generic.List[string]
$fontRoleIdents = New-Object System.Collections.Generic.List[string]
-$rowFiles = @($incPath)
-if (Test-Path $devIncPath) { $rowFiles += $devIncPath }
+$rowFiles = @(Get-RowFiles $incPath)
+$selfFiles += @($rowFiles | ForEach-Object { Split-Path -Leaf $_ })
foreach ($line in ($rowFiles | ForEach-Object { Get-Content $_ })) {
if ($line -cmatch '^\s*CFG_(?[A-Z_]+)\(\s*(?[A-Za-z0-9_]+)\s*,') {
# ROWFLAGS and LABEL mark a row declared above it (config_registry_rows.inc); neither is a row.
diff --git a/.github/ci/release_sign.py b/.github/ci/release_sign.py
index e65f261ce..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,6 +427,53 @@ def _check_protection(environment, policies):
raise _unprotected("branch policies")
+# 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(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 _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 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 rule not in kinds:
+ return False
+ bypass = ruleset.get("bypass_actors")
+ 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_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):
"""The strict parse `sign --build` applies to its number: plain ASCII digits, no sign, no
underscore, no leading zero."""
@@ -435,8 +483,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 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:
raise SignError("usage", 2, "release_sign: --repo must be owner/name")
rows = _table_rows()
@@ -449,6 +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_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 897bd917f..898aca521 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 three
-commands keygen may run, 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
@@ -61,6 +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?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")
@@ -98,6 +109,55 @@ def canned_policies(*extra):
{"id": 3 + i, "node_id": "n%d" % i, "name": n, "type": t} for i, (n, t) in enumerate(items)]}
+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 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"], []),
+ 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):
"""The code under test ran a command the fake has no answer for."""
@@ -105,9 +165,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, 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.details = canned_details() if details is None else details
self.secret = secret or (lambda text: (0, GH_LINE, GH_WARNING))
self.calls = []
@@ -119,6 +181,12 @@ def __call__(self, argv, **kwargs):
answer = self.environment
elif argv == POLICIES_ARGV:
answer = self.policies
+ elif argv == RULESETS_ARGV:
+ answer = self.rulesets
+ 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)):
@@ -215,9 +283,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 = []
@@ -230,11 +299,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("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]))
+ 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]
@@ -315,8 +384,21 @@ 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)
+ # 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) == 10 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):
@@ -361,6 +443,46 @@ 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(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),
+ "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)")
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 +490,16 @@ 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)"),
}
cases = [(name, FakeGh(env, pol), UNPROTECTED % check_name)
for name, (env, pol, check_name) in refusals.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)
@@ -385,7 +514,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) == 7)
fake = FakeGh(secret=lambda text: FileNotFoundError("gh"))
r = drive(rs.cmd_keygen, fake, table)
check("keygen secret set cannot run: exit 3",
@@ -408,7 +537,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) == 6
and r["left"] == [])
diff --git a/.github/ci/reliablekind_gate.py b/.github/ci/reliablekind_gate.py
index c81982243..c21a88110 100644
--- a/.github/ci/reliablekind_gate.py
+++ b/.github/ci/reliablekind_gate.py
@@ -39,12 +39,20 @@
parser cannot read, or any file it cannot open. It fails CLOSED: a gate that cannot
read its subject must not return PASS.
+ 5. The receive edge no longer asking the authorship table, or no longer refusing Host and NotAKind with a
+ return before the save-transfer diversion and the inbox; or the table (`client_authorship.h`, which
+ the host's receive edge reads to refuse a kind a client may not author) out of step with the enum: an
+ enumerator with no row, a row named twice, a row that is not Client or Host, a row for a name that is
+ not a kind, or a relayable kind that is not Client (the edge would refuse what the relay forwards).
+
WHAT IT DELIBERATELY DOES NOT HOLD. `session_lanes.h`'s three switches -- `LaneForKind`,
`IsClientRelayableReliableKind`, `IsPreWorldSendableKind` -- all default safely (Normal /
no-relay / hold-until-world-ready), and whether a given kind SHOULD relay is a design
decision no text scan can make. Absence there is legitimate for every host-authored kind.
So they are reported in `--table`, not gated: this file must not manufacture a failure it
-cannot justify, or the next author learns to route around it.
+cannot justify, or the next author learns to route around it. The authorship table is the
+exception, by design: it is total -- every kind decided, Client or Host -- and only its
+membership is checked here, never whether a decision is right.
Note that membership in `session_lanes.h` is NOT receiver coverage. That is not a detail:
the 2026-09-01 dead kind WAS in that header (79 case labels), so a gate written around
@@ -65,6 +73,14 @@
PROTO = "include/coop/net/protocol.h"
LANES = "src/coop/net/session_lanes.h"
SESSION = "src/coop/net/session_receive.cpp" # the receive switch and the admission handler
+AUTH = "src/coop/net/client_authorship.h" # which kinds a client may author (rule 5)
+# The receive edge's question, which is the only reason the table matters.
+EDGE_READS_TABLE = re.compile(r"AuthorshipOf\(\s*static_cast\(\s*rh\.kind\s*\)\s*\)")
+# What a kind's row may say; NotAKind is the fallback for a byte that names no kind, never a row.
+ROW_VALUES = ("Client", "Host")
+# The edge's frame: the host-only wrapper, the table's answer named, and the test that opens the refusal.
+EDGE_SHAPE = re.compile(r"if\s*\(\s*cfg_\.role\s*==\s*Role::Host\s*\)\s*\{\s*const\s+ClientAuthorship\s+(\w+)\s*=\s*"
+ r"AuthorshipOf\(\s*static_cast\(\s*rh\.kind\s*\)\s*\)\s*;\s*if\s*\(")
# The five family dispatchers. Each Handle*Event returns true iff msg.kind is in its
# family, so its own switch IS the membership declaration (SyncRouter consolidation,
@@ -187,7 +203,7 @@ def strip_comments(text: str) -> str:
def read_tree() -> dict:
"""Every file the gate reasons about, as text. One dict so --drill can mutate a copy."""
- want = {PROTO, LANES, SESSION, FEED_FILE} | set(FAMILY_FILES)
+ want = {PROTO, LANES, SESSION, FEED_FILE, AUTH} | set(FAMILY_FILES)
for spec in NET_THREAD_TERMINAL.values():
want |= {f for f, _rx, _what in spec["must"]}
tree = {}
@@ -200,6 +216,77 @@ def read_tree() -> dict:
return tree
+def matching(src: str, i: int) -> int:
+ """src[i] opens a bracket: the index just past the bracket that closes it, or -1."""
+ pair = {"(": ")", "{": "}"}[src[i]]
+ depth = 0
+ for j in range(i, len(src)):
+ if src[j] == src[i]:
+ depth += 1
+ elif src[j] == pair:
+ depth -= 1
+ if depth == 0:
+ return j + 1
+ return -1
+
+
+def edge_findings(src: str) -> list:
+ """The receive edge, comment-stripped, read as code: on the host only, the table's answer is tested at once
+ for exactly Host or NotAKind, the block that test opens ends in a return, and all of it comes before the
+ save-transfer diversion and the inbox. Tokens alone are not enough: a removed Host test once left
+ `ClientAuthorship::Host` standing in the log line's ternary."""
+ m = EDGE_READS_TABLE.search(src)
+ if not m:
+ return [f"{SESSION} no longer asks AuthorshipOf about a received kind: the table below would "
+ f"refuse nothing, however complete it is."]
+ after = [src.find(tok, m.end()) for tok in ("ReliableKind::SaveTransferChunk", "reliableInbox_.emplace_back")]
+ if min(after) < 0:
+ return [f"{SESSION}: the AuthorshipOf check no longer precedes the save-transfer diversion and the "
+ f"inbox, so a refused kind would be acted on first."]
+ shape = EDGE_SHAPE.search(src)
+ if not shape or not shape.start() < m.start() < shape.end():
+ return [f"{SESSION}: the edge is no longer `if (cfg_.role == Role::Host) {{ const ClientAuthorship who = "
+ f"AuthorshipOf(...); if (...)`: only the host asks, and it tests the answer at once."]
+ who = shape.group(1)
+ cond_end = matching(src, shape.end() - 1)
+ cond = " ".join(src[shape.end():cond_end - 1].split())
+ want = f"{who} == ClientAuthorship::Host || {who} == ClientAuthorship::NotAKind"
+ out = []
+ if cond != want:
+ out.append(f"{SESSION}: the edge's test is `{cond}`, not `{want}`: a Host kind and a byte naming no "
+ f"kind are refused, and nothing else is.")
+ body_start = cond_end + len(src[cond_end:]) - len(src[cond_end:].lstrip())
+ body_end = matching(src, body_start) if src[body_start:body_start + 1] == "{" else -1
+ if body_end < 0 or not re.search(r"\breturn\s*;\s*$", src[body_start + 1:body_end - 1]):
+ out.append(f"{SESSION}: the edge no longer returns on a refusal, so a refused kind goes on to the "
+ f"inbox and the relay.")
+ elif body_end > min(after):
+ out.append(f"{SESSION}: the edge's refusal block runs past the save-transfer diversion or the inbox.")
+ elif not src[body_end:].lstrip().startswith("}"):
+ out.append(f"{SESSION}: the host-only block holds more than the table's question and its refusal, "
+ f"so what follows the refusal there runs on every message a client sends.")
+ return out
+
+
+def parse_authorship(text: str):
+ """client_authorship.h's AuthorshipOf: [(kind, row)] in source order, one per case label."""
+ src = strip_comments(text)
+ i = src.find("AuthorshipOf(")
+ if i < 0:
+ return None
+ rows, pending = [], []
+ for line in src[i:].splitlines():
+ for k in CASE.findall(line):
+ pending.append(k)
+ r = re.search(r"return\s+ClientAuthorship::(\w+)\s*;", line)
+ if r and pending:
+ rows.extend((k, r.group(1)) for k in pending)
+ pending = []
+ if re.match(r"^\}", line):
+ break
+ return rows
+
+
def parse_enum(text):
"""Return (name -> value-or-None, [unreadable rows]).
@@ -325,6 +412,33 @@ def idof(n):
f"whether the file still MENTIONS the kind is not enough -- both "
f"these files name these kinds in unrelated places.")
+ # --- check 5: the authorship table is total and in step ------------------------
+ fails.extend(edge_findings(strip_comments(tree[SESSION])))
+ rows = parse_authorship(tree[AUTH])
+ if rows is None:
+ fails.append(f"{AUTH}: no AuthorshipOf switch found -- the receive edge's table cannot be checked.")
+ else:
+ seen = {}
+ for k, row in rows:
+ seen.setdefault(k, []).append(row)
+ for k in sorted(set(seen) - set(enums)):
+ fails.append(f"{AUTH} has a row for '{k}', which is not a ReliableKind.")
+ for k in sorted(set(enums), key=lambda x: (enums.get(x) is None, enums.get(x) or 0)):
+ if k not in seen:
+ fails.append(f"{k} = {idof(k)} has no row in {AUTH}: decide whether a client may author it "
+ f"(Client) or not (Host). A kind with no row is refused at the host's edge.")
+ elif len(seen[k]) > 1:
+ fails.append(f"{k} = {idof(k)} is named {len(seen[k])} times in {AUTH}.")
+ for k, rs in sorted(seen.items()):
+ if rs[0] not in ROW_VALUES:
+ fails.append(f"{k}'s row in {AUTH} is {rs[0]}; a kind's row is one of "
+ f"{', '.join(ROW_VALUES)}.")
+ relay = parse_lane_switches(tree[LANES])["relay"]
+ for k in sorted(relay):
+ if k in seen and seen[k][0] != "Client":
+ fails.append(f"{k} is relayable but {seen[k][0]} in {AUTH}: the edge would refuse what the "
+ f"relay forwards.")
+
# --- check 4: two families claiming one kind -----------------------------------
for name in sorted(set(enums), key=lambda k: (enums.get(k) is None, enums.get(k) or 0)):
who = [pathlib.PurePosixPath(rel).name for rel, got in fam.items() if name in got]
@@ -462,7 +576,7 @@ def drill() -> int:
# 3. a family stops being chained: every case label survives, the call does not.
t = dict(base)
- t[FEED_FILE] = t[FEED_FILE].replace("if (HandleWorldEvent(session, msg)) break;", "", 1)
+ t[FEED_FILE] = t[FEED_FILE].replace("if (HandleWorldEvent(msg)) break;", "", 1)
controls.append(("a family dispatcher no longer chained", t,
"HandleWorldEvent is defined in"))
@@ -484,6 +598,50 @@ def drill() -> int:
t[PROTO] = "// the enum is gone"
controls.append(("an unparseable enum", t, "expected >=100"))
+ # 7-10. the authorship table (rule 5).
+ row = lambda k: " case ReliableKind::" + k + ":\n"
+ t = dict(base)
+ t[AUTH] = t[AUTH].replace(row("ChatMessage"), "", 1)
+ controls.append(("an authorship row removed", t, "ChatMessage = "))
+ t = dict(base)
+ t[AUTH] = t[AUTH].replace(row("ItemActivate"), "", 1).replace(
+ row("PropConvert"), row("PropConvert") + row("ItemActivate"), 1)
+ controls.append(("a relayable kind made Host", t, "ItemActivate is relayable but Host"))
+ t = dict(base)
+ t[SESSION] = t[SESSION].replace("AuthorshipOf(static_cast(rh.kind))", "ClientAuthorship::Client", 1)
+ controls.append(("the receive edge no longer reads the table", t, "no longer asks AuthorshipOf"))
+ t = dict(base)
+ t[SESSION] = t[SESSION].replace("RefusalTally::Clock::now());\n }\n return;\n",
+ "RefusalTally::Clock::now());\n }\n", 1)
+ controls.append(("the receive edge no longer returns on a refusal", t, "no longer returns on a refusal"))
+ t = dict(base)
+ t[SESSION] = t[SESSION].replace("who == ClientAuthorship::NotAKind", "false", 1)
+ controls.append(("the receive edge accepts a byte naming no kind", t, "the edge's test is"))
+ t = dict(base)
+ t[SESSION] = t[SESSION].replace("who == ClientAuthorship::Host || who == ClientAuthorship::NotAKind",
+ "who == ClientAuthorship::NotAKind", 1)
+ controls.append(("the receive edge accepts a host-only kind", t, "the edge's test is"))
+ t = dict(base)
+ t[SESSION] = t[SESSION].replace("who == ClientAuthorship::NotAKind)",
+ "who == ClientAuthorship::NotAKind && false)", 1)
+ controls.append(("the receive edge's test can never be true", t, "the edge's test is"))
+ t = dict(base)
+ t[SESSION] = t[SESSION].replace("if (cfg_.role == Role::Host) {\n const ClientAuthorship who",
+ "if (cfg_.role == Role::Client) {\n const ClientAuthorship who", 1)
+ controls.append(("the receive edge asks on the wrong peer", t, "the edge is no longer"))
+ t = dict(base)
+ t[SESSION] = t[SESSION].replace(
+ " }\n }\n // payloadLen is a uint16",
+ " }\n return;\n }\n // payloadLen is a uint16", 1)
+ controls.append(("a return after the refusal ends every message", t, "holds more than the table's"))
+ for value in ("NotAKind", "Unverified"):
+ t = dict(base)
+ t[AUTH] = t[AUTH].replace(row("ChatMessage"), "", 1).replace(
+ " return ClientAuthorship::Host;\n }\n",
+ " return ClientAuthorship::Host;\n" + row("ChatMessage")
+ + " return ClientAuthorship::" + value + ";\n }\n", 1)
+ controls.append(("a kind's row is " + value, t, "ChatMessage's row in"))
+
bad = 0
for label, mutated, expect in controls:
got, _e, _f = check(mutated)
diff --git a/.github/ci/tag_regex_selftest.ps1 b/.github/ci/tag_regex_selftest.ps1
index 25e190c7e..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
@@ -34,7 +35,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 +55,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/ci/verify_latest.ps1 b/.github/ci/verify_latest.ps1
index bae36cc42..2ae742010 100644
--- a/.github/ci/verify_latest.ps1
+++ b/.github/ci/verify_latest.ps1
@@ -1,5 +1,6 @@
-# verify_latest.ps1 -- the STABLE-ritual closing check (design D3): after every master's env
-# constants are updated, each master's /v1/latest must report exactly the newest published stable.
+# verify_latest.ps1 -- the STABLE-ritual closing check (design D3): after every master's COOP_LATEST_GAMES is
+# updated, each master's /v1/latest must list every game's newest published stable under `games`, and its
+# legacy `proto` must be the 0.9.0n entry's -- the game every client older than `games` targets.
# The masters are the official slots in protocol.h (kOfficialMasterSlots): a player reads the update
# check from the master they chose, so one master left behind tells its players a wrong verdict.
# Fold-aware (R23): "the newest BARE-tag row whose state(N) == PUBLISHED" -- a retracted N has a
@@ -35,10 +36,14 @@ if ($MasterLatestUrls.Count -eq 0) {
}
$kind = if ($AllowDev) { 'published (dev admitted)' } else { 'published stable' }
-$expected = Get-NewestPublished -Rows (Read-Ledger -Path $LedgerPath).Rows -IncludeDev:$AllowDev
-if ($null -ne $expected) {
- Write-Host "verify_latest: ledger newest $kind = N=$($expected.N) game=$($expected.Game) tag=$($expected.TagName)"
+$rows = (Read-Ledger -Path $LedgerPath).Rows
+$perGame = Get-NewestPublishedPerGame -Rows $rows -IncludeDev:$AllowDev
+foreach ($g in $perGame.Keys) {
+ Write-Host "verify_latest: ledger newest $kind for $g = N=$($perGame[$g].N) tag=$($perGame[$g].TagName)"
}
+# The legacy fields answer clients older than `games`, all made for this game (server/src/lobby.rs LEGACY_GAME).
+$legacyGame = '0.9.0n'
+$expected = if ($perGame.ContainsKey($legacyGame)) { $perGame[$legacyGame] } else { $null }
$failed = 0
foreach ($url in $MasterLatestUrls) {
@@ -52,21 +57,40 @@ foreach ($url in $MasterLatestUrls) {
$masterProto = 0
if ($resp -and ($resp.PSObject.Properties.Name -contains 'proto')) { $masterProto = [int]$resp.proto }
+ Write-Host "verify_latest: $url reports proto=$masterProto mod='$($resp.mod)'"
+ $hint = if ($AllowDev) { '' } else { ' -- if the masters were deliberately pointed at a DEV prerelease, re-run with -AllowDev' }
+ # The legacy pair: the legacy game's newest, or no record when the ledger has none for it.
if ($null -eq $expected) {
- if ($masterProto -le 0) {
- Write-Host "verify_latest: OK_EMPTY -- no $kind in the ledger; $url has no released record (proto<=0)"
- continue
+ if ($masterProto -gt 0) {
+ Write-Host "verify_latest: FAIL -- $url reports proto=$masterProto but the ledger has NO $kind for $legacyGame (unrecorded release?)$hint"
+ $failed++
}
- $hint = if ($AllowDev) { '' } else { ' -- if the masters were deliberately pointed at a DEV prerelease, re-run with -AllowDev' }
- Write-Host "verify_latest: FAIL -- $url reports proto=$masterProto but the ledger has NO $kind (unrecorded release?)$hint"
+ } elseif ($masterProto -ne $expected.N) {
+ Write-Host "verify_latest: FAIL -- $url legacy proto $masterProto != ledger N $($expected.N), the newest $kind for $legacyGame (COOP_LATEST_GAMES not updated / stale?)"
$failed++
- continue
}
-
- Write-Host "verify_latest: $url reports proto=$masterProto mod='$($resp.mod)'"
- if ($masterProto -ne $expected.N) {
- Write-Host "verify_latest: FAIL -- $url proto $masterProto != ledger N $($expected.N) from the newest $kind (env constants not updated / stale?)"
- $failed++
+ # Every game: each one the ledger has, at its newest; none the ledger does not.
+ $listed = @{}
+ if ($resp.PSObject.Properties.Name -contains 'games') {
+ foreach ($e in @($resp.games)) { $listed[[string]$e.game] = [int]$e.proto }
+ }
+ foreach ($g in $perGame.Keys) {
+ if (-not $listed.ContainsKey($g)) {
+ Write-Host "verify_latest: FAIL -- $url lists no release for $g under games (ledger N $($perGame[$g].N))"
+ $failed++
+ } elseif ($listed[$g] -ne $perGame[$g].N) {
+ Write-Host "verify_latest: FAIL -- $url lists $g at N=$($listed[$g]), ledger N $($perGame[$g].N)"
+ $failed++
+ }
+ }
+ foreach ($g in $listed.Keys) {
+ if (-not $perGame.ContainsKey($g)) {
+ Write-Host "verify_latest: FAIL -- $url lists $g at N=$($listed[$g]) but the ledger has NO $kind for it (unrecorded release?)$hint"
+ $failed++
+ }
+ }
+ if ($perGame.Count -eq 0 -and $listed.Count -eq 0 -and $masterProto -le 0) {
+ Write-Host "verify_latest: OK_EMPTY -- no $kind in the ledger; $url has no released record"
}
}
diff --git a/.github/ci/workflow_expr_gate.py b/.github/ci/workflow_expr_gate.py
new file mode 100644
index 000000000..99c00381c
--- /dev/null
+++ b/.github/ci/workflow_expr_gate.py
@@ -0,0 +1,145 @@
+#!/usr/bin/env python3
+"""workflow_expr_gate -- no ${{ }} expression is pasted into a run: or script: value.
+
+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 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.
+
+USAGE
+ python .github/ci/workflow_expr_gate.py
+"""
+import pathlib
+import re
+import sys
+
+ROOT = pathlib.Path(__file__).resolve().parents[2]
+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:/script: value of one YAML file."""
+ found = []
+ column = None
+ for no, line in enumerate(text.splitlines(), 1):
+ 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
+ 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
+ flow = FLOW_KEY.search(line)
+ if flow and "${{" in line[flow.end():]:
+ 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 }}
+ - 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 }}
+ steps:
+ - name: env passes the value
+ if: ${{ inputs.go }}
+ env:
+ 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') }}
+"""
+
+
+def fixtures_hold():
+ bad = [no for no, _ in violations(FIXTURE_BAD)]
+ good = violations(FIXTURE_GOOD)
+ 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}")
+ 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..6cea97626 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
@@ -53,34 +55,25 @@ jobs:
with:
ref: ${{ inputs.ref }}
submodules: false
+ persist-credentials: false
# Full clone: submodule-pinned commits + vcpkg versions DB need history.
fetch-depth: 0
- 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 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)
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
@@ -232,8 +225,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 +266,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,23 +293,20 @@ 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)
+ - 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" `
+ pwsh -File .github/ci/package.ps1 -BuildDir "$env:BUILD_DIR/Release" `
-OutDir "$env:RUNNER_TEMP/package"
exit $LASTEXITCODE
@@ -322,7 +316,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..4d96bfa11 100644
--- a/.github/workflows/release-core.yml
+++ b/.github/workflows/release-core.yml
@@ -1,10 +1,15 @@
-# 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 /
-# 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.
name: release-core
on:
@@ -29,8 +34,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 rulesets let only an admin create, move or delete a v* tag or push to main.
permissions:
- contents: write # Release API only (D7); the v* tag ruleset blocks robot tags independently
+ contents: read
jobs:
judge:
@@ -46,6 +53,7 @@ jobs:
with:
ref: main
fetch-depth: 0
+ persist-credentials: false
- name: Self-check -- judge tree is origin/main HEAD
shell: pwsh
@@ -56,19 +64,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 +101,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 --
@@ -120,6 +136,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.
@@ -148,6 +166,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
@@ -178,18 +197,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 414b9e306..87900f646 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 --
@@ -142,5 +147,35 @@ 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
+
+ - name: Fact rows gate drill
+ run: python3 .github/ci/fact_rows_gate.py --drill
+
+ - name: Fact rows gate (every watched or hooked game function is a well-formed row with a reference)
+ run: python3 .github/ci/fact_rows_gate.py
+
+ - name: Data paths gate drill
+ run: python3 .github/ci/data_paths_gate.py --drill
+
+ - name: Data paths gate (the mod's own data is built from its own folder; ExeDir and the null-module path stay in their files)
+ run: python3 .github/ci/data_paths_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
+
+ - 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
+
+ - name: Server tests (the master and the relay)
+ run: cargo test --locked --manifest-path server/Cargo.toml
diff --git a/.gitignore b/.gitignore
index 2e04e310c..df7a7c850 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,11 +1,13 @@
# ===== Game install — never commit copyrighted binaries or assets =====
-Game/
+# Anchored to the root, where the installs live: unanchored, Game_*/ also took src/.../coop/game_build/ on a
+# case-insensitive checkout.
+/Game/
# Our own toolset. Untracked whole: it is the workflow that maintains this project, not
# something a person forking it runs. What the CI genuinely needs lives in .github/ci.
tools/
-Game_*/
+/Game_*/
*.exe
*.dll
*.pak
@@ -241,6 +243,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
@@ -257,6 +260,7 @@ docs/**
!docs/status.md
!docs/votv-ui-style.md
!docs/devices.md
+!docs/base-computer.md
!docs/deployables.md
!docs/events-and-weather.md
!docs/join.md
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.
diff --git a/README.md b/README.md
index dd8e4dcfa..052f3afcd 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 182 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/THIRD-PARTY-NOTICES.md b/THIRD-PARTY-NOTICES.md
index fbfca4eed..ecc68a050 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_bytecode.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/docs/README.md b/docs/README.md
index f15a8306e..717964b56 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:
@@ -61,7 +62,8 @@ messages, the late-join answer, the known limits with their evidence, and the co
| [npcs-and-kerfur.md](npcs-and-kerfur.md) | Host-owned characters, the creatures each peer owns, the killer wisp, the roaches, and the kerfur robot across its two forms |
| [events-and-weather.md](events-and-weather.md) | The clock, the weather, who rolls, the story events and how a client sees one fire, the event actors, the pyramid, the alarm, emails and the task |
| [signals.md](signals.md) | The signal workstation: occupancy, the desk, the download simulation, the catch and the dishes, the deck, drives and racks, the laptop, the database, and the upgrades |
-| [devices.md](devices.md) | Doors, keypads, lights, the power panel, the turbine, windows and grime, the drone, and the economy: the balance, shop orders, the coin gun |
+| [devices.md](devices.md) | Doors, keypads, lights, the power panel, the turbine, windows and grime, coffee appliances, the drone, the signal servers |
+| [base-computer.md](base-computer.md) | The base laptop and the economy: the balance, upgrades, the laptop's tab and inbox, shop orders, the coin gun, the floppy slot |
| [vehicles.md](vehicles.md) | The ATV: a mirror that runs the vehicle's physics natively and is corrected, the two authority predicates, the condition that travels |
| [ui.md](ui.md) | The native screens, the F1 overlay, the HUD, and the game's notifications |
| [send-path.md](send-path.md) | How fast each connection is paced, measured rather than configured; who may fill a send buffer, and where a held-back send waits |
diff --git a/docs/architecture.md b/docs/architecture.md
index 9f3da3311..565a5a118 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -127,8 +127,9 @@ refuses, and the packet header is the backstop.
## Identity
-**Peers.** Every install holds an Ed25519 keypair in a key file beside the game executable,
-under a private access list (`coop/net/peer_identity`); the public key *is*
+**Peers.** Every install holds an Ed25519 keypair in a key file in the mod's own folder,
+bound to the machine it was made on and under a private access list
+(`coop/net/peer_identity`); the public key *is*
the peer's network identity, and the id the master server and the per-player inventory store
use is derived from it. A name proves nothing by itself, so before a host spends
a seat the two ends sign each other's nonces with the key their identity names
@@ -192,7 +193,7 @@ a call is a property of how the Blueprint VM dispatches it, not of the function:
|---|---|---|
| the `ProcessEvent` interceptor | engine-originated calls: ticks, BeginPlay, input events, delegates, timers, interface events | yes |
| the script-body gate (`ue_wrap/core/script_gate`) | every Blueprint function body, on every route (an engine event, a Blueprint's own local call, a call through a context switch, an ubergraph entry; a virtual or final call to a script function ends in the same loop), with its arguments and the frame that called it | yes, per call |
-| the native function seam (`ue_wrap/core/ufunction_hook`) | calls into native functions on every route, including from inside a Blueprint's own graph | before the call, for a native with no return value and no out or reference parameter (its PRE form); after it, no |
+| the native function seam (`ue_wrap/core/ufunction_hook`) | calls into native functions on every route, including from inside a Blueprint's own graph | before the call, for a native with no return value and no out parameter or non-const reference (its PRE form); after it, no |
| per-site reconcile | anything else: let the verb run, diff the observable state, converge to the authority's answer | after the fact |
[coop-dispatch-visibility.md](coop-dispatch-visibility.md) is the per-function table and the
diff --git a/docs/base-computer.md b/docs/base-computer.md
new file mode 100644
index 000000000..2052a3adf
--- /dev/null
+++ b/docs/base-computer.md
@@ -0,0 +1,237 @@
+# The base computer and the station's economy
+
+## Purpose
+
+The laptop and the money the station runs on: the shared balance, the upgrade levels, the laptop's
+tab, its inbox, the shop orders and the delivery queue, the coin gun, and the floppy slot of a
+laptop or a signal server. Who owns each, how a client's purchase or order reaches the host, and
+where the group's money can still be lost. These ride the replication engine and the host-owned
+drone described in [devices.md](devices.md).
+
+## How it works
+
+### The balance
+
+The host owns the balance. It polls the points field every tick, which catches every writer, and
+broadcasts the absolute value on change and to a joiner; a client writes the host's value directly.
+The wire is one-way. A client-to-host balance delta once existed with no bound, so any peer could
+set the group's money to anything; it was retired whole rather than clamped
+(`coop/world/balance_sync`). A client's own earnings are therefore not shared unless a lane
+carries them as an intent.
+
+### The upgrades
+
+The eighteen upgrade levels are one struct on the save, so they used to ride only the transferred
+save and diverged in silence from the moment a level was bought. The host now polls the struct and
+broadcasts it whole on any change, which catches every writer: the panel, the physical racks, the
+transformer prop. A client applies what it is sent and repaints the rows it has open.
+
+A purchase is an intent. A client's button is refused at the script-body gate, so it never debits
+itself, and the host re-derives the price and the bounds from its own table and its own level,
+charges the shared balance and republishes. The rows have no identity of their own to name, so
+holding the laptop claim is the sender's reach, and the index must be one of the fifteen rows that
+buy a level. The host asks exactly what the button asks, including the game's own asymmetry: the
+balance is tested against the row's unaccumulated price and charged the accumulated one.
+
+### The laptop's tab
+
+The base laptop and every portable PC show one widget whose tabs are a switcher. The peer holding the
+laptop claim reports its index after each tab change (`coop/interactables/laptop_tab`); the host checks
+that the sender holds the claim and the index is one of its own tabs, writes it to its widget and sends
+it on. A peer that does not hold the claim writes the switcher's index and nothing else: the game's own
+tab call also binds that peer's keys to a remote drone. A joiner gets the host's index at its world-ready.
+
+### The laptop's inbox
+
+The laptop's messages -- the hash-collection task mails, the scientist and alien replies, the
+mails an event or a caught signal produces -- are the same on every machine, and deleting one
+deletes it for everyone. Every producer in the game funnels through one Blueprint-internal
+function into the save's email array, and calling that same function on a receiver reproduces the
+whole arrival at once: the stored row, the list entry, the ding at the physical laptop and the
+tab highlight, with the date re-stamped from the synced clock.
+
+Each peer keeps a shadow of the array and diffs it once a second. The array only ever grows at
+the tail, so the diff is positional. A new row is broadcast by the host -- see below -- and a
+removed row is broadcast by whoever deleted it, as the row's content HASH and never its index,
+because a producer writes its own row before that row reaches anyone and two peers therefore hold
+the same messages in different orders.
+
+Writing a mail is the host's alone: nothing in the game authors a mail from a player action, so a
+client that started producing them could only be a diverged simulation writing into everyone's
+permanent inbox, and a client's append is dropped at the host rather than relayed. Deleting is
+symmetric, because the only thing in the game that removes a mail is a player pressing the row's
+delete button (`coop/world/email_sync`).
+
+### Shop orders
+
+A client's laptop order is entirely local to its machine: the game's order function queues it in
+the client's own save and hands it to the client's own mirror drone. So on a client the order function's
+queueing (`addOrderCart`) and its send to the drone (`sendShop`) are refused at the script-body gate, and
+the order goes to the host as an intent naming each item's shop row and nothing else, read from the
+function's own parameter at its entry; the host prices the row from its own
+store table, checks its own balance, rolls its own delivery time, commits through the game's own
+order function and charges (`coop/items/order_sync`). An order a world event makes on a client (the
+daily delivery, a gift) is not sent: the host's copy of the event makes it. A refused order comes back with a reason, and the refused items
+are put back in the client's cart, because the game's own affordability gate runs before the cart
+is cleared while the refusal arrives after. This is the reference intent lane: the intent used to
+carry a client-chosen price, and every client shopped free.
+
+The intent names a shop ROW, not an object class, because a class cannot name an item: the game's
+473 shop rows map onto 368 distinct classes, and `prop_C` alone is shared by 50 of them, so 112
+rows have no unique class. The row name is the shop's real identity, and the game stamps it into
+each generated store entry, so a forwarded order already carries it.
+
+The host's price comes from the game's own `list_store` table, read two independent ways -- a walk
+over the row map, and a fully reflected column read that needs no layout knowledge. They are
+compared, and a single disagreement invalidates the whole catalog: the host then refuses client
+orders outright rather than charge a number it cannot vouch for. Setting
+`VOTVCOOP_STORE_CATALOG_BREAK=1` makes the walk read the wrong field on purpose, so that refusal
+can be seen firing before it is trusted (`ue_wrap/world/store_catalog`).
+
+The delivery queue is the host's. Each change to it -- an order queued, the delivered one taken off --
+goes to every client as the laptop's own queue functions run, and a client's queue follows through
+the same functions (`coop/items/order_queue_sync`). A shop item travels by its row; an item a world
+event built outside the shop has no row and travels by its class, and by the list_props name a generic
+prop carries in its asProp (the daily delivery's reel case), rebuilt in the one shape every such builder
+makes. A change waits, in its place, while the client's laptop, its shop catalog or the engine's name
+conversion is not ready yet; an item the client can never build is left out, down to an order of no
+items that still keeps its place, so the host's next delivery takes the same order off both queues; and
+a change counts as applied only when the queue moved by exactly one.
+
+### The coin gun
+
+Shooting a prop with the coin gun sells it: the game destroys the prop and mints coins whose
+material is their denomination. A client's shot is uncancellable, so the lane captures the
+client's own coins at their birth and releases or destroys them at the next barrier, sends a sale
+intent naming the prop by key ahead of the client's ordinary destroy on the same lane, and the
+host resolves the prop in its own world, prices it from its own copy, mints through the gun's own
+sell function and destroys the sold prop itself; the coins are host-owned world actors that the
+event-actor mirror carries, and whoever's body trips one on the host credits the host, a client's
+puppet included (`coop/items/coingun_sync`). The client is told the result.
+
+### The floppy slot
+
+A laptop and a signal server hold a disc the same way: inserting one moves its type, its remaining
+writes, its data rows and the JSON of its whole save struct into four fields of the device and
+destroys the actor, and ejecting one spawns the disc back from those fields. The four are one
+concept with one owner, and they are state, not an event: the host holds every device's slot, each
+peer polls its own devices once a second behind a digest that reads the raw field bytes, and a peer
+whose own game changed a slot sends the host the outcome as a claim. The host applies it and answers
+with the canonical, which is also the acknowledgement (`coop/interactables/floppy_slot_sync`).
+
+The laptop also edits the files of the disc it holds, which its file quad carries as edit scripts
+(`coop/interactables/laptop_buffer_sync`). So the laptop's slot is watched for which disc it holds
+alone: the rows and the writes travel with a change of that and are the quad's between two. A
+canonical naming the disc a laptop already holds leaves its files alone, and the slot lane primes
+the quad at each change it takes -- a claim sent, a publish, a canonical written -- so an edit made
+after it is the quad's to send, even one made before the host answered the claim. The host counts
+each change of the laptop's disc; the quad's batches and canonicals carry that count on the slot's
+lane, a batch made on another disc is answered with the canonical instead of applied, a canonical of
+another disc is dropped, and neither end applies or sends one while its own change of the disc waits
+for the other.
+
+Emptying a slot writes only what the device's own eject writes -- the type and the rows. The eject
+runs in two phases: it clears those two, and about a second later, when the carrier's timeline
+finishes, the deferred spawn reads the remaining writes and the save JSON to rebuild the disc.
+Anything that zeroes those in between hands the player back a blank disc under a new identity.
+
+A disc a peer ejects reaches the others through the ordinary birth channel: a client's own fresh
+prop spawn is not broadcast, so its device's eject is reported to the host, which authors the disc
+and broadcasts it like any other world prop. Three other lineages travel that way, and all three
+are born into a hand and spawn inert on the host until the holder's pose stream drives them; a
+disc is not held by anyone, so it falls on the host instead. Either way the disc's files and
+remaining writes travel as its save record behind the birth; a disc the host ejects gets its record
+from the host's finish-spawning drain, since the eject fills the disc in after the finish call
+([props.md](props.md)).
+
+The laptop's slot crosses as one blob, and the save JSON carries the rows a second time, so a disc
+with a few dozen rows is kilobytes. The blob is held to the ceiling the server slot uses and refused
+whole past it, never cut: a cut JSON is one the receiver's own eject cannot parse, and the disc it
+hands back comes out blank under a new key. The laptop keeps at most 31 rows in the slot itself
+(`ui_laptop.updFloppy` trims `floppyData`); the JSON keeps all of them.
+
+A device takes a disc by two entries, and only one of them is a player. Pressing E with a disc in
+hand is deliberate, happens on one machine, and the slot carries its outcome. The other is the
+hitbox reporting whatever touches the slot -- and an eject spawns the disc INSIDE the box it came
+out of, which the Blueprint handles by turning that device's own hitbox off for a moment. In single player
+the only birth that can land in a slot is that eject, so guarding the one box is enough; in coop
+the disc is also born on the other machine, in a box whose hitbox nobody turned off and where
+nobody ejected anything. So the rule sits on the disc rather than the box: a disc is in transit for
+a moment after it materialises, and no device swallows one in transit, on any peer. The window is
+anchored at the disc's own appearance on each machine, so both peers run one rule against one local
+event and neither waits on a message; when it lapses, the native rule simply resumes.
+
+The window is one number for every device, because the mark is on the disc and a disc does not know
+which slot it came out of. It has to clear the frame or two the hitbox entry needs to fire, and it
+has to end before the device that ejected re-enables its own hitbox -- re-enabling a collider
+re-reports every body already inside it, which is how the game re-takes a disc still sitting in the
+slot, and that report comes once. The two devices disagree on the pause: a signal server waits a
+second, a laptop half of one. The window is set under the shorter of the two, so neither device's
+own re-take is touched and an ejecting peer behaves exactly as it does in single player.
+
+The hitbox also takes a disc another peer is holding. That peer's pose drives this machine's copy
+of the disc, and bringing it to the slot brings the copy into this machine's copy of the slot, so
+the watching peer would insert it too and claim a second insert of one disc. The entry refuses a
+disc under another peer's hold on every peer; the holder's own insert is the one the slot carries.
+
+## Who owns what
+
+| State | Owner | Shape |
+|---|---|---|
+| the balance | the host | one-way, absolute, on change |
+| the upgrade levels | the host | the struct whole, on change; a purchase is an intent |
+| an order | the client names the row; the host performs and prices | an intent |
+| a coin gun sale | the client names the prop; the host prices, mints and destroys | an intent ahead of the destroy |
+| a device's floppy slot | the host | a 1 Hz digest-gated poll; a peer claims the outcome of its own insert or eject, and the host's canonical is the answer |
+
+## Wire messages
+
+| Kind | Direction | Carries |
+|---|---|---|
+| `LaptopTabReport`, `LaptopTabState` | the claim holder to the host; the host to the other clients | the switcher's index |
+| `BalanceSync` | the host to all | the absolute balance |
+| `UpgradeLevels` | the host to all | the eighteen levels, whole |
+| `UpgradeIntent` | a client to the host | the panel row's index and whether it is a buy or a sell |
+| `OrderRequest`, `OrderRefused` | a client to the host; the host to one client | the items by row; a refusal and its reason |
+| `OrderQueue` | the host to all | a change to the delivery queue: a reset, an order queued (its items by row or by class), the first one taken off |
+| `CoinGunSell`, `CoinGunResult`, `CoinCollect` | a client to the host; the host to one client; a client to the host | the sold prop's key; the outcome; a coin the client tripped |
+| `FloppySlotState` | a peer to the host with a claim; the host to all with the canonical | one device's slot, or a set of them -- the server boxes' and the laptop's: the type, the writes, the rows and the save JSON, and the laptop's generation |
+
+## Late join
+
+The balance is sent to a joiner at connect. The delivery queue goes to a
+joiner as a reset and then every queued order, and the joiner ignores the queue's changes until that
+reset arrives.
+
+Every device's floppy slot goes to a joiner at the same edge, an empty one as much as a full one:
+the joiner's world came from the host's save file, which coop stops the game refreshing, so it knows
+nothing the host has done since. The laptop's file quad follows the laptop's slot on the same lane
+and under the same count, so the joiner takes the disc before its files, and a set the transport
+refused is sent again with the quad behind it.
+
+The inbox rides the joiner's save transfer, so it arrives whole. A mail written during the 30 to
+60 seconds the joiner spends loading is in neither that save nor any later diff, so the host
+captures the array at the instant it hands over the save and sends the difference on the joiner's
+ready edge -- once per slot. A mail that arrives before the joiner's world can take it is parked
+in arrival order and applied once the array settles, rather than dropped on the spot; a row the
+settled world still refuses after thirty attempts is malformed for that world and is dropped with
+an error line.
+
+## Known limits
+
+| Limit | Evidence |
+|---|---|
+| 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 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 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 |
+
+## Code map
+
+| Concept | Files |
+|---|---|
+| the laptop's tab | `coop/interactables/laptop_tab` |
+| the floppy slot | `coop/interactables/floppy_slot_sync`, `coop/interactables/laptop_buffer_sync`, `ue_wrap/devices/floppy_slot`, `ue_wrap/devices/serverbox`, `ue_wrap/devices/laptop` |
+| the inbox | `coop/world/email_sync`, `ue_wrap/world/email`, `coop/session/join_seed` |
+| the economy | `coop/world/balance_sync`, `coop/items/order_sync`, `coop/items/order_queue_sync`, `coop/items/coingun_sync`, `coop/interactables/upgrade_sync`, `ue_wrap/world/economy`, `ue_wrap/world/order_economy`, `ue_wrap/world/store_catalog`, `ue_wrap/world/upgrades` |
+| tests and probes | `coop/dev/order_selftest`, `coop/dev/floppy_selftest` |
diff --git a/docs/bytecode-fingerprint.md b/docs/bytecode-fingerprint.md
new file mode 100644
index 000000000..6eead586c
--- /dev/null
+++ b/docs/bytecode-fingerprint.md
@@ -0,0 +1,225 @@
+# 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, and its name, follow UAssetAPI's expression readers (MIT): an expression is written with the
+name of its `EExprToken` less `EX_`. 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]` (every function of a capture: 21,087 on 0.9.0n, 22,762 on 0.10.0, in a world). Expressions nested more than
+256 deep are refused as a corrupt script; a compiled Blueprint nests a few dozen deep.
+
+### The canonical text
+
+1. **Line 1 is the signature**: `sig(` then the function's parameters (the properties flagged
+ `CPF_Parm`) in declaration order, comma-separated, each `name:flags:type`, then `)` and a newline.
+ The flags are letters in the order C (const), P (parameter), O (out), R (return), F (reference). The
+ type is the name of the property's class (`objectproperty`, `intproperty`, ...), followed by:
+ - `:` of the property's type object, when it has one, for an object, soft object, weak
+ object, lazy object, class or soft class property (the class it holds: `class` for a class
+ property), an interface property (its interface), a struct property (its struct), a byte property
+ (its enum, if it has one), and a delegate or any multicast delegate property (its signature
+ function);
+ - for an enum property, `:`;
+ - for an array or set property ``, for a map property ``, each
+ written by the same rule, to a depth of four (deeper, the class name alone).
+ 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 ` `. Every statement of the script is
+ read first, from offset 0 to its end. From the entry a straight run is followed: an unconditional
+ `EX_Jump` is layout glue, followed and never written, and `Jump`, `Return`, `EndOfScript`,
+ `PopExecutionFlow` and `ComputedJump` end a run. Every other code offset an expression holds -- a
+ conditional branch, a pushed execution flow, a latent action's resume point -- is deferred, last in
+ first out, in operand order. A run that reaches a statement already written ends with
+ ` >@`. Labels are indices in this order, and every code offset is written `@` (`@?` if
+ it is not a statement). A recook that re-lays a function out without changing it therefore writes the
+ same text.
+3. **An expression** is `Name(operand,...)`, or `Name` alone when it has no operand. Operands, in the
+ reader's order:
+ - `o:`: the object's outer chain, without its package, joined by `.` (`o:-` for null);
+ - `p::` for a property: the path of the first object that owns it, then the
+ property names from the outermost field in, joined by `.` (`p:-` for null); `l: ` for a
+ frame's own property (rule 4);
+ - `n:`; the integers in decimal, `i:` (int32), `b:` (byte), `h:` (uint16), `l:` (int64), `u:`
+ (uint64), and `B:0`/`B:1`; `f:` a float's 32 bits as 8 lowercase hex digits, `d:` a double's as
+ 16; `s:"..."` a string; `[...]` a list, comma-separated.
+ Dropped, as derived from the layout: `EX_Context`'s skip offset, `EX_Skip`'s offset,
+ `EX_SwitchValue`'s goto offsets and the element counts of container constants. `StructConst` keeps
+ its struct's serialized size as the script writes it (`i:`). The expressions with a form of their own:
+ - `TextConst(t:,...)`: the kind is `Empty`, `LocalizedText` (then its source, key and namespace
+ expressions), `InvariantText` or `LiteralString` (one expression), or `StringTableEntry` (then
+ `o:`, its id and its key);
+ - `SwitchValue(,[(,),...],)`;
+ - `PrimitiveCast(k:,...)`: `ObjectToInterface` (then `o:`), `ObjectToBool`,
+ `InterfaceToBool`, or `?` for another, then the cast expression;
+ - `VectorConst` and `RotationConst` (pitch, yaw, roll) write their three floats, `TransformConst` its
+ ten (rotation x, y, z, w, translation x, y, z, scale x, y, z), each `f:`;
+ - `InstrumentationEvent(b:)`, and for an inline event (type 4) `,n:` (only a build
+ compiled with instrumentation carries one).
+4. **A frame's own property** -- one whose owner is a function: a parameter, or a slot the compiler made
+ (`CallFunc_*`, `K2Node_*`, `Temp_*`) -- is written `l: `: `k` by first appearance in the text
+ (restarting where rule 6 says), `base` its own name with every `_` part after the first taken
+ out. The compiler numbers its slots across a whole event graph and a plugin node numbers inside the
+ name (`K2Node_LowEntry_LocalVariable_Value__12_Object`), so an edit elsewhere renumbers both.
+5. **Text.** Names, paths and types are folded to printable ASCII and lowercased: a name renders in the
+ casing first registered in the process, which the game cannot know from the asset. A name with a
+ number writes it as `_`, as the engine does. A string keeps its case; any character
+ outside printable ASCII becomes `?`, one per UTF-16 unit. In a name, a path and a string alike, a `"`
+ or a `\` is written with a `\` before it.
+6. **Events.** An event function is a stub that calls its class's event graph
+ (`ExecuteUbergraph_`) at an entry offset. The call is written
+ `(o:,[IntConst(@U),])`, and the text continues with
+ `== ==` and the event graph's statements in flow order from that entry, labels
+ restarting, frame numbering shared with the stub. An `ExecuteUbergraph_*` function itself is written as
+ `== entry ==` and its flow from each stub's entry, the stubs of its class sorted by their
+ rendered name (then entry offset), frame numbering restarting for each. If any function of the class
+ does not decode, the event graph is undecodable: a stub left out would be a region left out.
+7. **Fingerprints** are FNV-1a 64 over the UTF-8 text: one over the signature line, its newline
+ included (a change means a hook reads wrong parameters), and one over the rest (a change means the
+ logic drifted).
+
+### Agreement
+
+The text is rendered twice: by the mod from the running game, and by the port tooling from the cooked
+assets (UAssetAPI's expression tree). A difference between the two is a defect in one of the readers.
+Over every loaded Blueprint function the cooked assets also hold, the two agree `[V]`: on 0.9.0n 21,008
+of 21,068 captured are identical and none differs; the other 60 have no counterpart among the pak's
+Blueprint classes (48 natives, five native event declarations such as `actor.receivetick`, and the
+seven functions of the level's own script class, which lives in the map). On 0.10.0, 22,744 of 22,756
+are identical and none differs; the other 12 are the level's script class and a function library outside
+the pak's Blueprints.
+
+### The registry
+
+Every game function the mod watches or hooks is a **row**, declared once in the file that registers it:
+
+ UE_FACT_FN(kRowFix, "serverbox_sync", L"serverBox_C", L"fix", Declared);
+
+with the lane that relies on it (a lowercase id), the class (`nullptr` for every class's function of that
+name), the function and how it resolves: `Declared` (the class declares it), `Dispatch` (the class or its
+nearest ancestor), `AnyClass` (a name watch on every class), `RuntimeClass` (the class is known only at
+run time; the site resolves it with `facts::ResolveOn`, which records the class's name for the census).
+The registration calls of `script_gate`, `ufunction_hook` and `game_thread` accept only a row or a
+`facts::Resolved` made from one, so a dependency cannot be added without its row, and each row records
+whether its registration was taken or refused. A function a constant the mod writes depends on (the string a game spawner
+stamps into an actor before its BeginPlay reads it) is a row too, resolved by the site that writes the constant,
+though nothing hooks it, so a build that moved the function is named by the census.
+
+### The census
+
+The census starts once a process, in its first gameplay world, however the player got there -- a load or
+a join from the menu, or a test launcher's scenario -- and checks every row against the **reference
+table** of the build the mod was made for (`include/ue_wrap/facts/fingerprints_.inc`, generated
+by the port tooling from that build's pak and the mod's capture on it). A new row needs its entry there:
+the maintainer regenerates the table, and the source gate refuses a push whose rows it does not key.
+
+A class row is resolved as its registration resolves it and fingerprinted. A `RuntimeClass` row with no
+class is keyed on the class its site resolved; the table carries an entry for every class that declares
+its function, and the row's key in the report is `*.`. An any-class row checks the signature of
+every function of that name against the reference's set as its class loads: the object index logs every
+class it lists, the census reads each class's functions once from where it stopped, and the row stays
+open for the classes a session loads later, so no walk of the object array is ever repeated.
+
+Each row ends as `match` (an any-class row: `holds`), `sig-changed` (a hook reads wrong parameters),
+`body-changed` (the logic drifted), `appeared` (present, though the reference build lacks it), `gone`
+(absent though the reference has it), `known-gone` (absent on the reference build too), `unchecked` (its
+class is not loaded, or no function of an any-class row's name is; off the reference build a class not
+loaded and a class gone cannot be told apart, and the row says so; a class row is tried again when the
+class set moves and every 5 s, an any-class row when a class loads), `undecodable`, or `no-reference`
+(the table cannot judge it: a run-time class it has no entry for). Every outcome but `match`, `holds`,
+`known-gone` and `unchecked` is a problem.
+
+The work is sliced at 2 ms, one slice a runtime loop, in a game-thread task of its own so that another
+lane's fault in the per-frame work cannot keep it from finishing, with each report in a task of its own.
+A slice reads nothing while the object index has the engine's events still to apply, and a class
+unloading during a pass drops the pass's caches, so no function is read through a pointer its class took
+with it. A pass reads the 423 rows in about 45 ms of work, its worst slice one whole-event-graph row at 6
+to 8.5 ms `[V]`.
+
+`multivoid-compat-report.txt` carries one line per row --
+` -> live / ref /`, then
+`(not registered this session)` or `(registration refused)` where so, and `-- ` -- with a line
+saying how many listed classes the any-class rows have met, how many were gone before they were met (a
+class can load with the world and be dropped first, so an any-class row's answer can differ from run to
+run) and which are still loading, and the summary. The file opens with the game build that runs and the
+boot's engine checks (written at start, so a session that never reaches a world has a report too) and
+ends in one verdict over every section: every census problem row counts as an important failure, and
+until the census has reported the verdict reads PENDING, never HEALTHY. The health log gets the summary,
+`fingerprint-census: N of M class rows match, ...`, with the costliest rows and the met line, when the
+first pass ends, when a product row's outcome moves, and when a registration is refused; a count that
+moves, a dev row, or a registration accepted (at most once a second) rewrites the file alone. Each
+problem row and each refused registration warns once, naming its lane; rows of dev instruments are
+counted apart, out of the verdict, and warn only while their instrument has registered them. Measured on
+0.10.0 against the 0.9.0n reference, the census named 42 problem rows by lane -- three of them functions
+whose only change is statements trading places (the known limit below) -- and agreed with the port
+tooling's diff of the two builds' paks on all 266 rows both could judge `[V]`.
+
+`[dev] fingerprint_census_red=.` falsifies one row's reference: the census must report
+it `body changed` and print `fingerprint-census: RED ARM reported body changed` once the row is
+settled, or `RED ARM FAILED ` -- also at once when no row has that key or the key names an any-class
+row. While the row is not settled it says once `fingerprint-census: RED ARM waits for its row`.
+
+### The capture
+
+`[dev] fingerprint_capture` writes, once the census's first pass has met the loaded classes, every
+function of every loaded Blueprint class, every row's resolved function and every function an any-class
+row names, natives included, with its fingerprint and text, sorted by path, to
+`multivoid-fingerprints-capture.txt` beside the game's exe: `#row -> ` lines first, then for each function `### ` and its
+text, where the fingerprint is FNV-1a 64 over the whole text in 16 hex digits and a path may hold a
+space. It is about a second of game-thread work.
+
+## Who owns what
+
+`ue_wrap/facts` holds the rows and resolves them; `ue_wrap/core/script_bytecode` decodes;
+`ue_wrap/core/script_fingerprint` orders, renders the signature and hashes; `harness/fingerprint_census`
+runs the census, `harness/fingerprint_capture` the capture; `harness/compat_report` writes the report
+file and its verdict, which `sdk_check` and the census both feed. A source gate
+(`.github/ci/fact_rows_gate.py`, on every push) holds every row well formed and present in the target's
+reference table (a run-time class row with no class: on at least one class). All of it reads; none of it
+changes the game.
+
+## Wire messages
+
+None: a fingerprint is a local check of the game the mod runs on.
+
+## Late join
+
+Not applicable: every peer checks its own game, once a process, in its first gameplay world.
+
+## Known limits
+
+- Two statements that swap places inside one straight run read as a change although nothing depends on
+ their order: 19 of 362 functions that differ between 0.9.0n and 0.10.0 in the hooked Blueprints `[V]`.
+ It errs toward reporting.
+- A native function is compared by its signature; its body is not readable.
+- Only loaded classes are read in the game; the port tooling reads every class of the pak.
+- An any-class row is checked by signature only: its overrides' bodies are many, and a name watch depends
+ on the parameters it reads and on who calls it.
+
+## Code map
+
+| File | What |
+|---|---|
+| `src/votv-coop/src/ue_wrap/core/script_bytecode.cpp` | the decoder: the opcode table, one expression scanned or rendered, a script's statements |
+| `src/votv-coop/src/ue_wrap/core/script_fingerprint.cpp` | flow order, the signature line, the fingerprints, and `script_calls::EnumerateCalls`, the call expressions in the same order |
+| `src/votv-coop/include/ue_wrap/facts/function_row.h` | the row macro, the modes, `Resolve` and `ResolveOn` |
+| `src/votv-coop/include/ue_wrap/facts/fingerprints_.inc` | the generated reference table |
+| `src/votv-coop/src/harness/fingerprint_census.cpp` | the census |
+| `src/votv-coop/src/harness/fingerprint_capture.cpp` | the capture |
+| `src/votv-coop/src/harness/compat_report.cpp` | the report file's one writer |
+| `.github/ci/fact_rows_gate.py` | the rows' source gate |
diff --git a/docs/code-map.md b/docs/code-map.md
index 2834e64e3..288ca58ad 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 |
@@ -18,26 +19,28 @@ One folder, one concept, named after it. `src/votv-coop/src/` holds the implemen
| Folder | Concept | Key files |
|---|---|---|
-| `coop/net/` | transport, sessions, admission, the wire protocol | `protocol.h` (every message kind and the build number), `session` (GameNetworkingSockets host and client, four peers, the lanes) with `session_streams` (the unreliable per-tick streams), `stream_slot` + `remote_streams` (what a peer keeps of each stream it receives: the newest value behind a sequence latch, grouped by the owner whose edge resets it) and `outbound_streams` (what it sends, reset whole at a session's start), `eid_pose_lane` (the host-originated pose lanes keyed by element id, the send queue and the receive merge) and `session_*` send helpers, `session_start` (LAN or P2P entry), `session_serial` (a serial per session start), `signaling_client` + `ice_config` (NAT traversal through the master server) with `signaling_proof` (the Ed25519 answer to the relay's registration challenge, its own file because the bytes are a contract with the relay and the release gate), `lobby_client` + `lobby_announcer` (the server browser's list) with `master_slots` (which masters there are and which one is chosen), `peer_identity` + `peer_admission` + `lobby_password` (the Ed25519 identity, the mutual challenge, the password), `connect_history` (the per-source history primitive, one instance for the connection cap and one for the password-guess bound), `intent_bucket` (a client's budget of intents on the host, one per sender slot), `end_reason` (the stable code a close carries), `send_backlog` (a reliable send is delivered or the connection dies), `blob_chunks` (large transfers), `http_client` |
+| `coop/net/` | transport, sessions, admission, the wire protocol | `protocol.h` (every message kind and the build number), `session` (GameNetworkingSockets host and client, four peers, the lanes) with `session_streams` (the unreliable per-tick streams), `stream_slot` + `remote_streams` (what a peer keeps of each stream it receives: the newest value behind a sequence latch, grouped by the owner whose edge resets it) and `outbound_streams` (what it sends, reset whole at a session's start), `eid_pose_lane` (the host-originated pose lanes keyed by element id, the send queue and the receive merge) and `session_*` send helpers, `session_start` (LAN or P2P entry), `session_serial` (a serial per session start), `signaling_client` + `ice_config` (NAT traversal through the master server) with `signaling_proof` (the Ed25519 answer to the relay's registration challenge, its own file because the bytes are a contract with the relay and the release gate), `lobby_client` + `lobby_announcer` (the server browser's list) with `master_slots` (which masters there are and which one is chosen), `peer_identity` + `identity_key_file` + `identity_crypto` + `peer_admission` + `lobby_password` (the Ed25519 identity, its key file and the primitives under both, the mutual challenge, the password), `connect_history` (the per-source history primitive, one instance for the connection cap and one for the password-guess bound), `intent_bucket` (a client's budget of intents on the host, one per sender slot), `latest_wins` (a client's changes of one display state on the host, applied at a pace with the newest kept), `end_reason` (the stable code a close carries), `send_backlog` (a reliable send is delivered or the connection dies), `blob_chunks` (large transfers), `http_client` |
| `coop/dispatch/` | routing of reliable messages | `event_feed` drains and dispatches; `event_dispatch_entity`, `_state`, `_signal`, `_intent`, `_world` are the five family routers, each owning its message kinds |
-| `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/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`, `world_ready_answer` (the host's answer to a client's ClientWorldReady: first completes the join, a repeat flood is rate-capped then ended), `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/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/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/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` + `player_hit` (a puppet's hit verbs refused; a parked creature's hit delivered to its peer), `hit_judge` (the host's judge of a swing a peer says it made), `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` + `sleep_rest` (who rests; a client's rest at the game's native rate), `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` (every live keyed prop by key, for a key on the wire; its key reads are `ue_wrap/actors/prop_keys`) + `prop_tracked_keys` (the props this peer tracks by key), `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`, `creature_hit` (what a creature is, for a hit on a player), `creature_hit_intent` (a client's hit on a parked creature, judged and run by the host), `npc_mirror_brain` (a parked family's mirror runs none of its Blueprint graph: the mark, the graph watches, and the table of entries they allow), `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`; `cord_sync` (corded prop to socket links); `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`, `laptop_tab` (the tab the PC's user is on, shown on every peer), `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_badsun`, `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`, `creature_event_relay` (the host's creature events in a client's event count), `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`, `explosion_sync` (the host's explosions on every client: a client dissolves its own game's explosion births and spawns the host's copy) + `explosion_parking` (what a client copy may still move: its own player's damage and knock-back, the scorch) |
| `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/comms/` | text chat | `chat_sync`, `chat_rate` (how fast a client may speak, judged on the host: Source's interval and two-tier bucket), `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`) |
| `coop/voice/` | voice chat | `voice_capture`, `voice_chat`, `voice_playback` (Opus, 3D positional) |
| `coop/moderation/` | kick, and bans by player id and address | `moderation`, `ban_list`, `seen_players` |
| `coop/permissions/` | who may ask the host for what (LuckPerms' model) | `context_set`, `node` + `node_map` (a holder's nodes), `model` + `inheritance` (users, groups, parents), `resolution` (a player's resolved answers), `permission_files` (the store's files: read, loaded, written one holder at a time), `permission_edit` (an edit of one holder, planned through the loader), `action_log` (the action log's record and its writer), `permission_host` (the hosted server's live model), `grants_core` (the projected table and its rules) |
| `coop/save/` | the host owns the save | `save_transfer` (streams the host's world to a joiner), `slot_file_read` (a slot file read once it holds still), `save_guard`, `save_block`, `save_button_disable`, `save_indicator_suppress` |
-| `coop/server_profile/` | the server a host runs, as a folder beside the game | `server_profile` (a server's id, the hosted server's folder, its creation at a host start) |
+| `coop/server_profile/` | the server a host runs, as a folder in the mod's data folder | `server_profile` (a server's id, the hosted server's folder, its creation at a host start) |
+| `coop/game_build/` | which game build this process runs, and what it means for this Multivoid | `game_build` (the target, a known newer or older build, or one no row names; the `[dev] game_build_force` drill arm; the popup's remembered answer; the key the Join and the lobby carry), `last_builds.inc` (the last Multivoid build for each game version left behind) |
| `coop/text/` | text encoding and names | `utf8_codec` (the one owner of decoding), `repertoire` + `repertoire_ranges` (what this build can draw), `case_fold`, `mark_ranges`, `ignorable_ranges`, `exclude_ranges`, `novelty_ledger` |
-| `coop/config/` | the ini | `config`, `config_registry` + `config_registry_rows` (every key is a registered row with a description), `config_ini_write`, `config_review`, `config_selftest` |
+| `coop/config/` | the ini | `config`, `config_registry` + `config_registry_rows` (every key is a registered row with a description), `config_ini_read` (the ini file reader), `config_ini_write`, `config_review`, `config_selftest` |
| `coop/atomic_file/` | a file written whole: beside itself, flushed, moved in one step | `atomic_file` (the writer and its selftest) |
+| `coop/mod_data/` | the mod's own data folder, seen from the exe folder it left | `legacy_note` (one notice per file an older build kept beside the exe, and the warning for a session with no data folder) |
| `coop/input/` | who owns the keyboard | `input_owner` (the game, a text field, or the overlay) |
| `coop/dev/` | developer features and probes, ini-gated, off by default | `freecam`, `pos_hud`, `set_clock`, `spawn_npc`, `force_weather`, `event_force`, `event_trigger`, the `*_probe`, `*_watch` and `*_selftest` instruments and the `*_drill` drivers, `perf_probe`; `dev/director/` drives a player through a test scenario |
@@ -45,31 +48,46 @@ One folder, one concept, named after it. `src/votv-coop/src/` holds the implemen
| Concept | Files |
|---|---|
-| the Multiplayer menu | `multiplayer_menu`, `host_window_native`, `host_save_picker`, `host_session_settings`, `host_session_choices`, `browser_input_screens`, `end_reason_dialog`, `boot_warning_dialog`, `menu_sfx` |
+| the Multiplayer menu | `multiplayer_menu`, `host_window_native`, `host_save_picker`, `host_session_settings`, `host_session_choices`, `browser_input_screens`, `end_reason_dialog`, `boot_warning_dialog`, `game_build_dialog` (the popup naming a game this Multivoid was not made for), `menu_sfx` |
| the server browser | `server_browser_native` with `server_browser_rows`, `server_browser_actions`, `server_browser_panels`, `server_browser_surface` (which browser is open) and `server_browser_selftest`; `server_browser` is the ImGui fallback |
| 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`, `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`, `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.
## `harness/` — boot glue and scripted tests
`harness` (the scenario runner), `session_runtime`, `autotest` + `autotest_dispatch` and `harness/autotest/`
(one file per scenario: join churn, grab, ragdoll, weather, events, death, damage, pause guard, save UI,
-scan parity and more), `sdk_check`, `screenshot`, `mod_environment`, `harness_diag`, `browser_click_arm`
-(the test variables that open the browser, host and join as a player's click does). The launcher drives
+scan parity and more), `sdk_check`, `fingerprint_census` + `fingerprint_capture` (every watched or hooked game function checked
+against the build the mod was made for, [bytecode-fingerprint.md](bytecode-fingerprint.md); the dev capture), `compat_report`
+(the report file every check writes, and its one verdict), `compat_boot` (its sections the boot writes: the game build
+that runs, and the engine's checks with every search of the game's code), `screenshot`, `mod_environment`, `harness_diag`,
+`browser_click_arm` (the test variables that open the browser, host and join as a player's click does). The launcher drives
these from outside the game.
## `ue_wrap/` — the engine wrapper
| Folder | Concept | Key files |
|---|---|---|
-| `ue_wrap/core/` | the substrate | `sig_scan` (AOB resolution of `GUObjectArray`, `GNames`, `ProcessEvent`), `reflection` + `reflection_props` + `reflected_offset` (classes, properties, functions), `uobject_listeners` + `object_index` (the engine's object-created and object-deleted notifications, and the per-class index of live objects they keep), `call` (calling a reflected function), `hook` + `pe_detour` + `ufunction_hook` (the MinHook `ProcessEvent` detour and per-function interceptors), `script_gate` (a detour on the VM's script loop: a Blueprint function body observed and cancelled per call, with its arguments and its caller, on every route), `game_thread` (the posted-task pump; engine calls happen only there), `cached_obj_ref` + `gc_pin` (object lifetime across worlds), `sdk_profile` + `sdk_profile_names` (the per-game-version offsets and names), `fname_utils`, `fstring_utils`, `ftext_utils`, `field_io`, `engine_heap`, `asset_load`, `hot_path_guard`, `walk_timer`, `walk_census` (how often the finders walk the whole object array, and from which call sites), `log`, `trace`, `paths`, `types`, `hook_drill`, `pe_diag` |
-| `ue_wrap/engine/` | engine objects | `engine` (world, actors, spawning), `engine_pawn`, `engine_component`, `engine_widget` + `umg_build` (UMG), `engine_bones`, `engine_attach`, `engine_audio`, `engine_nav`, `data_table` (a data table's rows, walked in memory and read by column through the engine), `engine_save` + `save_capture` + `save_to_slot_hook` (the one detour on the engine's save function: a gate before a world save, a notice after) + `save_browser` + `gvas_meta` (save files), `engine_mainplayer`, `engine_playerragdoll`, `spawn_gate`, `actor_end_play` (the detour on the engine's `AActor::EndPlay`: every actor that began play, ending it, with the reason), `spawn_menu`, `scs_rig`, `world_identity` (which world is current) |
-| `ue_wrap/actors/` | the game's actor classes | `puppet` + `puppet_spawn`, `prop`, `kerfur`, `wisp`, `inventory`, `vitals` (the player's stats, one table, and the profile snapshot), `effects` (the player's status effects: the game's list, add, remove, and reset out of a joiner's save), `sleep`, `swinger`, `save_record` (the Fstruct_save codec, and the actor's own getData/loadData), `floppy_disc`, `begin_equipment`, `broom`, `container_openers` (the actors a far-standing container is opened through: the drone, its sack, the ATV), `container_view` (the local player's inventory screen: the container it shows, and the game's own close) |
-| `ue_wrap/devices/` | keyed devices | `door`, `door_box`, `lightswitch`, `passwordlock`, `power_control`, `garage`, `appliance`, `base_window`, `grime`, `windturbine`, `drone` + `drone_console` (its garage console, keyed by no lane: the host finds it by the presser's reach), `atv` + `atv_condition`, `laptop`, `portable_pc`, `floppybox`, `serverbox` |
+| `ue_wrap/core/` | the substrate | `sig_scan` (AOB resolution of `GUObjectArray`, `GNames`, `ProcessEvent`), `reflection` + `reflection_props` + `reflected_offset` (classes, properties, functions), `uobject_listeners` + `object_index` (the engine's object-created and object-deleted notifications, and the per-class index of live objects they keep), `call` (calling a reflected function), `hook` + `pe_detour` + `ufunction_hook` (the MinHook `ProcessEvent` detour and per-function interceptors), `script_gate` + `script_gate_names` (a detour on the VM's script loop: a Blueprint function body observed and cancelled per call, with its arguments and its caller, on every route; the name watches in a file of their own), `script_bytecode` + `script_fingerprint` + `script_calls` (a function's compiled bytecode read as canonical text and fingerprinted, and its call expressions listed in that order), `caller_locals` (a Blueprint caller's actor array and hit result edited in place from a seam inside its call), `game_thread` (the posted-task pump; engine calls happen only there), `cached_obj_ref` + `obj_keyed_cache` + `gc_pin` (object lifetime across worlds; one value held per class or function), `sdk_profile` + `sdk_profile_names` (the per-game-version offsets and names), `game_build` (which game build runs: the executable's link stamp and the pak's index hash, matched against `ue_wrap/facts/game_builds.inc`), `scan_ledger` (every search of the game's code, found, missed or not tried), `fname_utils`, `fstring_utils`, `ftext_utils`, `field_io`, `engine_heap`, `asset_load`, `hot_path_guard`, `walk_timer`, `walk_census` (how often the finders walk the whole object array, and from which call sites), `log`, `trace`, `paths`, `types`, `hook_drill`, `pe_diag` |
+| `ue_wrap/facts/` | the game facts the mod depends on | `function_row` (every watched or hooked game function, declared as a row in the file that registers it; the registration APIs take only rows), `fingerprints_.inc` (the generated reference table of the target build) |
+| `ue_wrap/engine/` | engine objects | `engine` (world, actors, spawning), `engine_pawn`, `engine_component`, `engine_widget` + `umg_build` (UMG), `engine_bones`, `engine_attach`, `engine_audio`, `engine_nav`, `data_table` (a data table's rows, walked in memory and read by column through the engine), `engine_save` + `save_capture` + `save_to_slot_hook` (the one detour on the engine's save function: a gate before a world save, a notice after) + `save_browser` + `gvas_meta` (save files), `engine_mainplayer`, `engine_player_verbs` (the two verbs that hurt a player pawn), `engine_playerragdoll`, `spawn_gate`, `actor_end_play` (the detour on the engine's `AActor::EndPlay`: every actor that began play, ending it, with the reason), `spawn_menu`, `scs_rig`, `world_identity` (which world is current) |
+| `ue_wrap/actors/` | the game's actor classes | `puppet` + `puppet_spawn`, `prop`, `kerfur`, `wisp`, `zombie` + `fossilhound` (what a creature's BeginPlay sets up for the eye, which its mirror still needs, and the zombie's hit feedback), `int_objects` (a hit as the game's interface carries it), `inventory`, `vitals` (the player's stats, one table, and the profile snapshot), `effects` (the player's status effects: the game's list, add, remove, and reset out of a joiner's save), `sleep`, `swinger`, `save_record` (the Fstruct_save codec, and the actor's own getData/loadData), `floppy_disc`, `begin_equipment`, `broom`, `container_openers` (the actors a far-standing container is opened through: the drone, its sack, the ATV), `container_view` (the local player's inventory screen: the container it shows, and the game's own close) |
+| `ue_wrap/devices/` | keyed devices | `door`, `door_box`, `lightswitch`, `cord`, `passwordlock`, `power_control`, `garage`, `appliance`, `base_window`, `grime`, `windturbine`, `drone` + `drone_console` (its garage console, keyed by no lane: the host finds it by the presser's reach), `atv` + `atv_condition`, `laptop`, `portable_pc`, `floppybox`, `serverbox` |
| `ue_wrap/desk/` | the signal workstation | `dish`, `dish_writers` (the toolgun's calibration tool and the uncalibrator), `console_desk`, `desk_ping` (the ping machine and its verdict), `sat_console` (the SAT console's terminal), `coords_panel`, `coord_tower` (the three triangulation towers), `comp_pane`, `device_screen`, `drive_chain`, `tape_caddy`, `phys_mods`, `saved_signals`, `meadow_store`, `signal_dynamic`, `space_renderer`, `desk_audio`, `daily_task` |
-| `ue_wrap/world/` | world singletons | `world_singleton` (one live instance of a class the game keeps one of, from the object index, of the running world), `world_instances` (every such instance of a class a level places several of, judged by the same rule), `daynightcycle`, `active_events` (the gamemode's running-event refcount and its senders), `skysphere`, `jellyfish_path` (the space jellyfish's path and its fish), `red_sky` (the gamemode's red sky toggle and its event), `directionalwind`, `game_rules`, `game_mode` (the mode enum and the running world's mode byte), `economy`, `order_economy`, `store_catalog`, `weapon_catalog` (what one swing of a held item can deal), `email`, `votv_lib` |
+| `ue_wrap/world/` | world singletons | `world_singleton` (one live instance of a class the game keeps one of, from the object index, of the running world), `world_instances` (every such instance of a class a level places several of, judged by the same rule), `daynightcycle`, `active_events` (the gamemode's running-event refcount and its senders), `skysphere`, `jellyfish_path` (the space jellyfish's path and its fish), `red_sky` (the gamemode's red sky toggle and its event), `bad_sun` (a Bad Sun copy's state and the live copies), `directionalwind`, `game_rules`, `game_mode` (the mode enum and the running world's mode byte), `explosion` (`explosion_C`: its classes, its blast parameters, its begin-play), `economy`, `order_economy`, `store_catalog`, `weapon_catalog` (what one swing of a held item can deal, and how far it reaches), `email`, `votv_lib` |
## `loader/` and `bootstrap/`
@@ -88,4 +106,4 @@ can show before the game's UI exists.
4. Own it in one file, in the folder of its concept. The late-join answer (what a peer who joins
mid-way receives) lives in the same file.
5. Add its row to the subsystem's doc, and a driver under `coop/dev/` behind a registered
- config flag when the lane can be exercised without hands (`*_selftest` is the shape).
+ config flag when the lane can be exercised without hands (`*_selftest` is the shape).
\ No newline at end of file
diff --git a/docs/coop-dispatch-visibility.md b/docs/coop-dispatch-visibility.md
index 4e5deebcd..adc46999e 100644
--- a/docs/coop-dispatch-visibility.md
+++ b/docs/coop-dispatch-visibility.md
@@ -66,8 +66,8 @@ the post observers (`ue_wrap/core/game_thread.h` declares them). `[V]`
| an interceptor | before the original; `true` cancels | a dispatch to suppress or replace | a Blueprint-internal call |
| the detour itself | the anchor of the three above | a transparent bypass darkens the whole layer | |
| a native detour (`ue_wrap/core/hook`) | on a raw native address | non-function natives: the save write, the swap chain's present, the level open | anything a function observer would want |
-| the native function seam (`ue_wrap/core/ufunction_hook`) | after a call whose target is native, on every route; its PRE form before the call, and it can refuse one with no return value and no out or reference parameter | `EX_CallMath`, final and virtual calls into natives, and dispatched events; the caller's object, the calling frame's function and locals, and the result, which the callback may overwrite | a script function called locally |
-| the script-body gate (`ue_wrap/core/script_gate`) | at the entry of a Blueprint function's body, on every route; a pre callback answers run or cancel, a post callback reads the result | the instance, the evaluated parameters, the caller's frame; a `ProcessEvent` call, an `EX_Local*` call, a call through `EX_Context` and an ubergraph entry alike | a native function; a watched body on a worker thread is counted and passed through |
+| the native function seam (`ue_wrap/core/ufunction_hook`) | after a call whose target is native, on every route; its PRE form before the call, and it can refuse one with no return value and no out parameter or non-const reference | `EX_CallMath`, final and virtual calls into natives, and dispatched events; the caller's object, the calling frame's function and locals, and the result, which the callback may overwrite; a post names its bytecode caller's call by the call's end offset (`CallerFrame::codeOffset`, matched with `script_calls::EnumerateCalls`) | a script function called locally |
+| the script-body gate (`ue_wrap/core/script_gate`) | at the entry of a Blueprint function's body, on every route; a pre callback answers run or cancel, a post callback reads the result | the instance, the evaluated parameters, the caller's frame; a `ProcessEvent` call, an `EX_Local*` call, a call through `EX_Context` and an ubergraph entry alike; a seam can read a watched script body's scope mark while the body runs (`script_gate::ScopeBits`) | a native function; a watched body on a worker thread is counted and passed through |
| polling | a throttled tick | the observable result of anything | the moment of the verb |
**The thread rule.** An observer or interceptor can fire on a parallel animation worker. A
@@ -96,8 +96,12 @@ Never spawn from an observer directly; post it. `[V]`
| a garage's `runTrigger` (`garage_C`: Open negated and its swing, unless it is still moving), the one writer of Open past `loadTriggerData` | Blueprint calls | no, to the detour | the script-body gate watches it BY NAME: after the body on every peer (a client once its world is ready), the garage lane sends the Open the call left; the lane's own apply writes Open and plays the swing without it `[RD]` |
| the SAT console terminal's `enterCommand` (a typed line: the text box's commit and the widget's key handler both call it with the member `command`), its `writeToLog` (every line it prints, the latent continuations' included) and its ubergraph (the continuations, where every write of `processing` lies) | Blueprint calls from the widget's own graph and latent resumes | no, to the detour | the script-body gate watches all three on `ui_console_C` BY NAME: on a client, `enterCommand`'s entry refuses a line whose command rests on the shared world and sends it to the host; on the host, a line runs on a terminal it keeps for the typist, that terminal's `writeToLog` entries go back to the typist, and its busy level is read at the exit of its `enterCommand` and ubergraph bodies `[V]` (the SAT console drill) |
| the toolgun's calibration tool's `init` (`tool_setDishCalibration_C`: the tool's float into the dish its toolgun hit, the dish at its index or of its name) and the uncalibrator's `playerHandUse_LMB` (`prop_uncalibrator_C`: the dish the player's hit names zeroed), the two writers of a dish's precision a player drives | Blueprint calls | no, to the detour | the script-body gate watches both BY NAME: on a client, once its world is announced, the entry (outside another of these verbs) puts back any dish its copy moved off the host's value and then reads every dish's precision, and the exit sends the dishes the body changed to the host as an intent, which the host performs and answers, to all once it performed any of it and to the author alone when it performed none; the host's own use crosses by its poll `[V]` (a client's uncalibrator, on a dish its copy had zeroed first, and calibration tool crossed in the precision drill, and a refused NaN was answered back to it; that a refusal's answer stays off other peers is `[RD]`, a two-peer run cannot see it) |
+| the toolgun's remover tool's `init` (`tool_remover_C`: destroys the actor the toolgun's `outHit` names), the only writer of a prop's removal by the toolgun | Blueprint calls | no, to the detour | the script-body gate watches it BY NAME: on a client whose world is announced the entry reads the toolgun's `outHit`, sends the hit element to the host as a request, destroys the tool and cancels the body, so the client's own copy goes only when the host's destroy comes back; a hit it cannot read, or a keyed prop it cannot name, is cancelled with a notice; an actor the session does not share runs the body; the host and a solo game run it `[RD]` (a press on the toolgun's own trace is `[RD]`; the drill calls `init` itself with the toolgun's `outHit` written) |
| the gamemode's `setPrec` (the average of every dish's precision into the desk's `DL_precMult`), called from its own graph by a 10 s looping timer | a script call from the gamemode's graph | no, to the detour | the script-body gate watches it BY NAME on `mainGamemode_C`: on a client the entry puts every held dish back to the host's value before the body averages them `[V]` (the precision drill's reader leg) |
+| the gamemode's `sleep` (`mainGamemode_C::sleep(bed, dropItem, ignoreRagdoll)`, a stub into the ubergraph: it sets `isSleep` and the world's dilation to 20), called by a bed's use (`bed_C`, with the bed) and by the player's faint (`mainPlayer_C`, with none) | Blueprint calls from the bed's and the player's graphs; a reflected call of ours from the sleep drills | no, to the detour | the script-body gate watches it BY NAME on `mainGamemode_C`, POST, on a client only (`coop/player/sleep_rest`): a flag still down means the game refused, else the world's dilation goes back to 1 and the rest begins (the game has no `isSleep` guard on `sleep`, so a call while a rest runs sets the 20 again: the dilation goes back to 1 and no new rest begins); the callers `[RD]`, the live firing `[?]` until the client rest drill reads it |
| a server box's `playerUsedOn` (the install of a held upgrade) and `actionOptionIndex` (action 4 at the upgrade bay, the take-out), the only writers of the box's `upgrades` past its load | Blueprint calls | no, to the detour | the script-body gate watches both on `serverBox_C` BY NAME: the entry reads the box's count and the exit sends the change -- a client's op to the host, the host's canonical to all; the lane's apply writes the count and runs `updUpgrades`, never a verb `[V]` (a client's install and take-out crossed in the server upgrade drill) |
+| the library's `setEvent` (`lib_C::setEvent(isEventActive, deactivateAmbientTrack, __WorldContext)`: the gamemode's `activeEvents` count and its `activeEvents_senders`), called by every event's and creature's graph with itself as the context | Blueprint calls from the callers' graphs; a reflected call of ours from the creature event relay | no, to the detour | the script-body gate watches it BY NAME on any class: after the body on the host, `event_active_sync` logs the begin or end and `creature_event_relay` counts an enrolled creature's call and sends its whole count; before the body on a client, a mirror the relay has bound is refused its own call, the relay's own application and the per-viewer classes run `[RD]` (the event relay drill) |
+| the event graphs of the zombie family and the fossilhound (`ExecuteUbergraph_npc_zombie`, the three zombie classes below it that own a graph, and `ExecuteUbergraph_fossilhound`: every BeginPlay, timer, delay resume, overlap and montage delegate and move callback of the class lands in one of them, told apart by the `EntryPoint` parameter) | Blueprint calls and engine events into the ubergraph function | no, to the detour | the script-body gate watches each BY NAME, Declared on its class, PRE: a MARKED mirror's call runs when its entry is in the class's table (the footstep notify, the fossilhound's per-viewer loop) and is refused otherwise; the host's creature and a client's own run whole, with no session test. A class with no graph of its own runs its parent's, which the parent's row matches; a child that owns a graph and calls its parent's BeginPlay is refused at its own entry first. The entry offsets are cooked and move with a game update `[RD]`; the refusals `[?]` until a run reads them |
| an appliance's `actionOptionIndex` (it negates the class's bool and repaints: a faucet's and a sink's action 5, a shower's action 5 while its `useType` is 0, the oven's switch while fixed, a tape unit's action 4 at its use box with both reels in) and a server box's `visual(bool)` (the kerfur Omega's), the writers of each class's bool past its load | Blueprint calls | no, to the detour | the script-body gate watches both BY NAME: after the body on every peer (a client once its world is ready), the appliance lane sends the bool the call left; the lane's own apply writes the bool and repaints without the action verb, and sets a server box through its `visual`, the echo the channel's apply guard holds back `[RD]` |
| a locker's `open(bool)` (`locker_C`: its toggle, action 10 or 11 unless blocked, and a murder kerfur's `openDoors` reach it) and the drone console's `actionOptionIndex` (`droneConsole_C`: action 10 or 11 negates `opened`), the writers of `opened` past the load | Blueprint calls | no, to the detour | the script-body gate watches both BY NAME: after the body on every peer (a client once its world is ready), the box lane sends the `opened` the call left; the lane's own apply to a locker runs its `open`, its echo, which sends nothing `[RD]` |
| a lid's `open(bool)` and `close()` (`prop_swinger_C`: its own setup (`set`, as it is built and begins play), its grab, damage, padlock and the tick's close once a swing comes to rest, and a cremator's `setClosed` call one of them; the setup's calls land before the lane indexes the lid and are counted, not sent), the writers of `opened` | Blueprint calls | no, to the detour | the script-body gate watches both BY NAME: after the body on every peer (a client once its world is ready), the container lane sends the `opened` the call left; the lane's own apply runs the same two, its echo, which sends nothing `[RD]` |
@@ -111,6 +115,13 @@ Never spawn from an observer directly; post it. `[V]`
| the quick-slot bar's rebuild (`updateSlotInv`) | Blueprint-internal, from ten sites -- the game mode, the player and the inventory screen | no, to the detour | the script-body gate watches it BY NAME, which is the only seam that fires inside a world load, where the pump does not drain and no tick of ours gets a sample; the mod's own re-issue of it is a reflected call and so reaches the detour like any other `[V]` |
| the pile's grab and re-pile verbs | `EX_LocalVirtualFunction`, the grab one through an interface context | no, to the detour | the script-body gate watches `toClump` and `playerGrabbed` and refuses each per call on a client, which covers every caller of them -- the collision component's overlap handler and the arir follower alike; the use input's pre observer still reads the aimed pile while it is alive `[V]` |
| a door's entry verbs (`actionOptionIndex` the press, `addDamage` a hit, `door_pryable_C::crowbarOpen` a pry) | Blueprint calls from the player's graph (the use press, the melee trace) and the crowbar's | no, to the detour | the script-body gate watches them BY NAME and refuses each per call on a client for a door the door lane indexes, sending it to the host, which runs the same verb on its own copy; a hit whose `actor` is not the local player is refused without a send. `[V]` for a press and a hit dispatched the way the door drill dispatches them; a player's own E and swing, and a pry, `[RD]` |
+| a security camera's use (`prop_camera_bad_C::actionOptionIndex`, action 8: it negates `active` and runs `updState`; the class's only action) | a Blueprint call from the player's graph (the use press) | no, to the detour | the script-body gate watches it Declared on `prop_camera_bad_C` (the subclasses inherit the body): PRE on a client refuses action 8 and sends the camera lane's key and the negated value to the host (`camera_toggle_intent`); POST on the host reads both bits and the camera lanes send a change. The lane's own apply never calls it (`updState`/`upd`), so the refusal cannot swallow it. `[V]` for a press dispatched the way the camera drill dispatches it; a player's own E `[RD]` |
+| a security camera's refresh verbs (`prop_camera_bad_C::upd`, and `updState` = `upd` plus the click when on), the end of every writer of the bits past the load: a player's use, the wisp's loop (`upd` from its ubergraph, about once a second near a despawning wisp), the gray events' `deac` and `deacCams` (`updState`), `loadData`, the laptop's light button | Blueprint calls, a local call from the wisp's ubergraph | no, to the detour | the script-body gate watches both Declared on the camera class, POST, on every peer: the host reads both bits and the lane sends a delta; a client's POST does nothing (the lanes are host-authored). `[V]` for the gray event's off and a reflected `updState`; the wisp's call `[RD]` |
+| the cursed camera's night roll (`prop_camera_cursed_C::ReceiveTick`, every 30 s: writes `active` and a random `cameraName`, and calls neither `upd` nor `updState`) | the engine's actor tick, a script event | no, to the detour | the script-body gate watches it Declared on `prop_camera_cursed_C`: PRE on a client cancels it for a cursed camera the camera lane indexes (the host rolls it), POST on the host reads both bits and sends a change. `[V]` for the client's refusal in a two-peer run; the host's send of a roll `[RD]` |
+| the laptop's camera-light button (`ui_laptop_C::BndEvt__umg_laptop_Button_camFl_K2Node_ComponentBoundEvent_31_OnButtonClickedEvent__DelegateSignature`: negates the selected camera's `isLight`, then `upd` and `updCam`) | the button's click delegate, a ProcessEvent call into the widget's graph | yes to a ProcessEvent hook; the script-body gate also sees it | the gate watches it Declared on `ui_laptop_C`, PRE: on a client it is cancelled and the widget's selected camera (`cam`) is sent with the negated value to the host, which writes the bit and calls `upd`; with no camera selected it runs natively (a sound and `updCam`). `[V]` for a click dispatched by reflection; a real mouse click `[RD]` |
+| the laptop widget's tab verb (`ui_laptop_C::switcherSwitched(Index)`: `SetActiveWidgetIndex` on `switcher`, then `createRC` on the cameras tab, else `leaveRC`) | an `EX_Local` call from the widget's ubergraph at ten call sites, and a reflected call by our drill | no, to the detour | the script-body gate watches it Declared on `ui_laptop_C`, POST: the peer holding the `"laptop"` claim reads `switcher`'s index and reports a change (`laptop_tab`). A viewer's apply writes the switcher through `SetActiveWidgetIndex` and never calls this body, so `createRC` is not run for it and no watch fires there. `[RD]` for a button; a call dispatched by reflection reaches the gate the way the camera-light click does |
+| the laptop widget's floppy tab button (`ui_laptop_C::BndEvt__ui_laptop_button_tab_floppy_K2Node_ComponentBoundEvent_54_OnButtonClickedEvent__DelegateSignature`: `updFloppy`, then `SetActiveWidgetIndex(10)` at ubergraph entry 22796) | the button's click delegate, a ProcessEvent call into the widget's graph | yes to a ProcessEvent hook; the script-body gate also sees it | Declared on `ui_laptop_C`, POST, the same report as above: this handler writes the switcher without `switcherSwitched`. `[RD]` |
+| the laptop widget's sub-game tab button (`ui_laptop_C::BndEvt__ui_laptop_Button_305_K2Node_ComponentBoundEvent_57_OnButtonClickedEvent__DelegateSignature`: `SetActiveWidget(canvas_subgame)` at entry 28169) | the button's click delegate, a ProcessEvent call into the widget's graph | yes to a ProcessEvent hook; the script-body gate also sees it | Declared on `ui_laptop_C`, POST, the same report. `[RD]` |
| the laptop's order verbs (`ui_laptop_C::makeAnOrder` and the two it calls, `addOrderCart` and the drone's `sendShop`; `removeOrderCart`, from the drone's arrival) | Blueprint calls from the widget's order button, the day cycle's hour, an eventer's gift and the drone's tick | no, to the detour | the script-body gate: on a client, `makeAnOrder`'s entry sends a player's order to the host and its `addOrderCart` and `sendShop` are refused (the queue and the drone are the host's); on the host, `addOrderCart` and `removeOrderCart` PRE and POST send the queue's change to every client `[V]` (the order selftest, a delivery's pop included) |
| the drone console's keyboard press (`droneConsole_C::actionOptionIndex`, action 4 with the presser's cursor on the keyboard: `triggerFly` on the console's drone) | Blueprint calls from the player's graph (the use press) | no, to the detour | the script-body gate watches it on the console's own class BY NAME and refuses it per call on a client, sending the press to the host, which runs the same verb on its own console once it holds the client's body; the lid's actions (10 and 11) run on every peer for the box lane `[V]` (the drone console drill, a rehost's second world included) |
| a portable PC's lid (`prop_portablePc_C::open`, the one writer of `opened`: the PC's actions 10 and 11 run it, so does its top's action 11, and the save does not write it) | Blueprint calls from the PC's own graph and its top's (a player's lid action) | no, to the detour | the script-body gate watches it on its own class BY NAME: the entry reads the lid, the exit sends the change (`coop/interactables/portable_pc_lid`, LaptopState op 6), and the host sends a client's line to every client, its author too; a receiver runs the same `open` inside the lane's scope, which sends nothing. `[V]` for the PC's own actions (the lid drill: the host's action-10 open and the client's action-11 close, each lane sending 1 line and applying 1); the top's action `[RD]` |
@@ -126,6 +137,14 @@ 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]` |
+| `mainPlayer_C::putObjectInventory2`, a player pocketing a prop (the pocket key, a gamemode or a device handing one over) | Blueprint call | no, to the detour | the script-body gate watches it BY NAME, PRE: on a client, a local player's pocket of a shared prop (a mirror) asks the host and is cancelled; the grant replays it under a latch and the commit makes the host destroy the prop (`coop/props/prop_act_request`); on the host, a pocket of a prop a client has reserved is cancelled; a puppet's body and a prop the session does not share run it `[RD]` |
+| `mainPlayer_C::Hold Object`, a player taking a prop into the hand | Blueprint call | no, to the detour | the same gate and lane as the pocket; the target is `manual`, else the grabbed or looked-at actor by the bytecode's own rule `[RD]` |
+| `prop_food_C::actionOptionIndex`, eating (action 4) a food | Blueprint call | no, to the detour | the same gate and lane, asked only for a call that destroys (the client's copy on its last use, edible). `prop_food_C`'s body is reached, for action 4 (eat), by `prop_food_C`, `prop_fish_C`, `prop_eriePlush_C` (overrides that call it) and the nine descendants with no body of their own (soap, bananaHusk, cf_egg, computerpanels, cookingFood, dingus3, coffeeCup, mre_AB, snuskLoaf); shrimp, the dingus, cookietray and breadmold_bread own their action 4 and run native `[RD]` |
+| `prop_food_mre_C::actionOptionIndex`, opening an mre (action 4) | Blueprint call | no, to the detour | the same gate and lane; the foods it opens into are born in the client's replay as its own spawns `[RD]` |
+| `prop_sleeppills_C::actionOptionIndex`, taking the sleep pills (action 4) | Blueprint call | no, to the detour | the same gate and lane `[RD]` |
+| `prop_food_soap_C::playerUsedOn`, soaping a dry sponge in the hand | Blueprint call | no, to the detour | the same gate and lane, asked only for the soap's last use; the replay passes the current hand view `[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) |
| `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) |
@@ -136,8 +155,15 @@ Never spawn from an observer directly; post it. `[V]`
| a Blueprint's physics velocity set (`SetPhysicsLinearVelocity`) | `EX_VirtualFunction` into a native, through a context switch | not to the detour; yes to the native seam | a broom's push, told from every other caller by the object whose bytecode made the call: a prop coasts on the driven-prop channel and a clump rolls on the clump stream `[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 Bad Sun's spawn (`Spawn Bad Sun`) | `EX_LocalVirtualFunction` through a context switch (`EX_Context` on the gamemode), from the day cycle's graph at midnight and the cheat menu's | invisible to a ProcessEvent hook | the script-body gate, BY NAME on the gamemode: the host sends whether a copy exists as the verb returns, a client refuses its own outside the lane's apply (`coop/world/weather_badsun`); `[V]` on a reflected call and on the day cycle's midnight route (the lane's drill), the cheat menu's route `[RD]` (its handler needs a Sandbox or flying world) |
+| `badSun_C::remove`, the copy's only path to its destroy | `EX_Context` on `sunObj` from its own `ui_badSun` widget, after the fade | invisible to a ProcessEvent hook | the script-body gate, class-scoped, POST: the host sends absent as the call returns; on every peer the copy's own end runs as in single player (`coop/world/weather_badsun`) `[V]` |
| 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 `addDamage` (the hit: `Add Player Damage`, then a punch above 25 when it has a source) | an interface call (`int_objects`) from the attacker's graph, a local call from `impactDamageCPP` | no | the script-body gate, class-scoped to `mainPlayer_C` (`coop/player/player_damage`, tag `PDAD`): refused on a peer's puppet on every machine, whoever calls it; on the host, a call whose caller object is a creature of a parked family is delivered to the puppet's peer as `PlayerHit`; on a client, a call on the local player from a parked creature's mirror is counted and never refused. That the interface route hands the creature as the caller object `[?]` (the creature hit drill logs it) |
+| the player's `punch` (a knock-down: `ragdollMode`, then `Add Player Damage`) | a Blueprint call from the attacker's graph and from `addDamage` | no | the same watch (tag `PDPU`) and the same three behaviours as `addDamage`, with the bone, the three vectors and the amount delivered `[RD]` |
+| the player's `ragdollMode` | a Blueprint call from the player's own graph and the attackers' | no | the same refusal on a peer's puppet (tag `PDRG`); nothing is delivered yet `[RD]` |
+| 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 parked creature mirror's `addDamage` (a client's swing at a creature: the mirror's own entry, the interface stub `mainPlayer_C::attack` calls on the struck actor) | an interface call (`int_objects`) from the attacker's graph; its body calls the class's ubergraph entry | no | the script-body gate, by name on any class (`coop/creatures/creature_hit_intent`, tag `CHIT`), PRE on a connected client: for a marked mirror of a parked family whose `actor` is this machine's player, whoever calls it, plays the zombie family's hit feedback, sends `CreatureHitIntent` and cancels, so the brain's entry refusal is never reached for a caught hit; every other call on a mirror falls to that refusal. The host judges the swing (`coop/player/hit_judge`) and runs the creature's own `addDamage` through the interface with a null damager. The hit's point read from the `hit` parameter `[?]` (the host's pass line logs the reach and target margins) |
| 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]` |
@@ -154,15 +180,17 @@ Never spawn from an observer directly; post it. `[V]`
| every credit and debit of points | `EX_LocalVirtualFunction` at all nineteen sites | no, to both | the economy is host-authored: the balance is polled, intents name artifacts `[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 keyed prop's key changing: `init` writes it from `getKey`, `loadData` writes the saved key and runs `init`, the `setKey` event writes it alone, and the one write the game makes to another object's key (the ATV's container) is followed by that object's `getKey` | Blueprint calls; through ProcessEvent only when we call them (a mirror's key, a re-key) | yes to the script-body gate, on every route; a post callback reads the key field after the body | name watches on all four, every class, keeping the classes with a key field (`coop/props/prop_key_index`); a prop born with its key set (a level's own props) is taken from the object index's births instead `[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]` |
| the toolgun's spawn (`tool_spawn_C`) | `EX_CallMath` from `ExecuteUbergraph_tool_spawn`, which carries its own copy of the three catalog branches and never calls the gamemode verb | not to the detour; yes to the native seam | the caller frame the native seam already holds names the ubergraph, and it finishes nothing else `[V]` for the bytecode, `[?]` for a run |
-| a hook's constraint build (`SetConstrainedComponents`, from `attach_a` or `makeAttachments`) | a final call into a native, from the graph | not to the detour; yes to the native seam, on every route | a client breaks every hook tie there; the host keeps its own and builds a client's on its mirror `[V]` |
+| a hook's constraint build: the `SetConstrainedComponents` inside `makeAttachments`, which `attach_a` calls after its own tie (seven statements follow, none builds one) and the ubergraph calls for an anchored load | the native call is a final call from the graph; `makeAttachments` itself is a Blueprint body entered by a local call | the native call, not to the detour; the body, yes to the script-body gate, on every route | a post watch on `makeAttachments` (`coop/items/hook_constraint`): a client breaks the tie in the frame that built it, before a physics step runs with it; the host keeps its own and builds a client's on its mirror `[V]` for the bodies in the decompile and for a run (`hookdrag` on `72742a09`, every role) |
| begin-play of a save-loaded actor | no dispatch the session sees | caught by the object scan at world start `[V]` |
-| begin-play of a runtime-spawned actor | the engine, through the actor's `ProcessEvent` | yes on a class that implements `ReceiveBeginPlay`; never on one that does not, since `AActor::ProcessEvent` returns first | observe the implementing class's own function `[V]` |
+| begin-play of a runtime-spawned actor | the engine, through the actor's `ProcessEvent` | yes on a class that implements `ReceiveBeginPlay`; never on one that does not, since `AActor::ProcessEvent` returns first | observe the implementing class's own function `[V]`. A remote player's puppet is already `IsPuppet` at this begin-play (PI-0) `[V]` (the PI-0 proof run: the probe's `[PI-0] PASS slot= puppet at begin-play` line on both roles). |
| game mode 5's reset of the clock (`halloweenMaster_C`, spawned by the gamemode in that mode) | its begin-play pushes six flows (the needs restore, the ambience, four spawners) and runs one pass of a loop that sets the cycle's `day` to 0 and waits a second; each wait resumes the ubergraph through `ProcessEvent` at the loop's entry, a Blueprint byte offset | the resume, yes to the script-body gate, with its `EntryPoint` | a client refuses the resume at that entry, so its loop ends after the begin-play's pass and the other flows run on (`coop/world/time_sync`): `[V]` on a master spawned in a joined world (the midnight drill's mode5 arm: the resume refused once, the needs restore still lifting the client's food), `[RD]` for the one a mode-5 world loads with |
| a cosmetic emitter spawn | `EX_CallMath` | no | the verb that spawns it: the cue lane watches `runEvent` return for the row whose body spawns the emitter (a script-gate watch), and a joiner's snapshot reads the live particle components from the object index `[V]` |
| the save write | native C++ | not to the detour | the native detour that blocks a client's world save `[V]` |
@@ -172,7 +200,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; 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]` |
@@ -188,6 +216,10 @@ Never spawn from an observer directly; post it. `[V]`
| the drive-chain, database and module verbs | `EX_LocalVirtualFunction` | no | the script-body gate brackets, then a poll `[RD]` |
| the UI input-mode verbs (`SetInputMode_UIOnlyEx`, `_GameAndUIEx`, `_GameOnly`) | `EX_FinalFunction` into a UMG native -- all 46 call sites in the cook, no other route exists | not to a ProcessEvent observer; YES to the native seam | the post-hook tells the mod when a UI surface can hold focus, which is what lets `input_owner` stop sweeping every UObject once a second `[V]` |
| the widget focus verbs (`SetKeyboardFocus`, `SetUserFocus`, `SetFocus` on `UWidget`) | `EX_FinalFunction` into a native -- 20 sites in 12 classes, 9 of which announce no input mode | the same | watched beside the input-mode trio, because the mode alone is a census of this cook and not an engine guarantee. NOTE a mouse click that moves Slate focus calls NONE of them: Slate handles it internally, and only the surface's input-mode announcement covers that case `[V]` |
+| a component's begin-overlap (or hit) stub whose handler does what only this machine's own player earns: the white wisp's capture with its saves and quit, the other wisps, kelura, the orb, the well, a dream boulder or dream box, a skull zone, a trigger box's map-leaving targets | the generated delegate stub, which stores its parameters in the graph's frame and calls the graph's entry | yes, as every delegate broadcast reaches `ProcessEvent` (the Visible paragraph, the door sensor row, the slot-entry row); the seam used is the script-body gate, by class-scoped name, which cancels per call with the frame in hand | the pre callback reads the stub's `OtherActor` and cancels the stub when it is a puppet body (`players::Registry::IsPuppetBody`); a trigger box's stub marks its scope instead (`MarkScope`), as does the purple wisp's on the host (next rows), and the two map-leaving targets' `runTrigger` bodies cancel while the mark is on the chain. This machine's own player and single player pass `[?]` |
+| the camera call a grunt's touch ends in: `Controller::SetControlRotation` on player 0's controller | a call into a native, synchronous inside the grunt's stub | not to the detour; yes to the native seam as a pre hook, which reads the const-reference parameter before the original runs | armed only while the grunt's stub has marked its scope for a puppet toucher; the pre hook cancels while that scope is on the chain and the call's object is a PlayerController (the one a coop world holds, this machine's, kept while its player is seated), so the grunt's slap, sound and damage run on `[?]` |
+| the purple wisp's touch on the host: `GameplayStatics::PlaySound2D` (the 2D sound) and `UserWidget::AddToViewport` (the flashbang) | calls into natives, synchronous inside the wisp_p stub | not to the detour; yes to the native seam as pre hooks, as the grunt's | armed only while the wisp_p stub has marked its scope for a puppet toucher; the pre hooks cancel while that scope is on the chain, so the stub's clock rewind (the host's world clock, carried by time_sync) runs on; on a client the stub is cancelled whole `[?]` |
+| the scans a Blueprint reads the result of: birch's five `GetOverlappingActors` calls, the angry erie's `SphereOverlapActors`, the shower's `LineTraceSingleForObjects` | `EX_FinalFunction` (wrapped in `EX_Context` for birch's) and `EX_CallMath` into natives | not to the detour; yes to the native seam as a post hook, which names its call by the caller's ubergraph and the code offset the call ends at | armed while a puppet exists; the post hook edits the out local in the caller's frame in place before the Blueprint reads it: puppets removed from the list (the erie's answer rewritten to match), the shower's hit losing its actor and component `[?]` |
## How to pick a seam
@@ -302,9 +334,9 @@ ubergraph: a mirror adopting an anchored hook runs `loadData` then `processKeys`
of whatever the hook was tied to -- no tick involved, and no `attach_a` either. So a parked mirror
can still mutate the receiving peer's world through an event the park never saw. Ask which dispatch
the park intercepts, then ask what else can reach the same write. `[V]` The write itself has a
-seam: every route into a hook's constraint ends in the native `SetConstrainedComponents`, and the
-native function seam on it fires for the mirror's `makeAttachments` as it does for the owner's
-`attach_a`; the hook lane breaks a client's tie there (`coop/items/hook_constraint`). `[V]`
+seam: every route into a hook's A-to-B tie runs through the body `makeAttachments`, which `attach_a`
+calls and so does the ubergraph for a mirror's load, and the script gate sees that body on every
+route; the hook lane breaks a client's tie in its post (`coop/items/hook_constraint`). `[V]` for the bodies and for a run (`hookdrag` on `72742a09`, every role: the client's "released the tie" lands before the host's tie)
A verb you dispatch to repaint a device can write PLAYER state, and the rate you call it at is
part of its meaning. `analogDScreenTest_C::updToggles` repaints the console's toggle LEDs and ends
diff --git a/docs/coop-entity-expression-map.md b/docs/coop-entity-expression-map.md
index 224884d29..b5789b4c5 100644
--- a/docs/coop-entity-expression-map.md
+++ b/docs/coop-entity-expression-map.md
@@ -13,7 +13,7 @@ from the lower half and each client from the upper half for the elements it crea
two trust gates check which range a sender may have allocated from. Every wire receiver is
host-authoritative except the kinds either range may send, a destroy and a convert. `[V]`
-**No passive mint.** A client's census walk key-indexes a keyed prop but mints no element: keyed
+**No passive mint.** A client's census walk tracks a keyed prop by its key but mints no element: keyed
identity is host-authored by construction, a save-loaded prop adopts the host's id by key, and a
client-born prop is minted at its express seam, which broadcasts in the same breath. A passive
census mint once produced about two thousand zombie double rows per join. The one bind owner
@@ -22,6 +22,14 @@ host-local actor, a client dissolves its provisional row on the host's word, and
row. On the host, a peer's spawn resolving onto a host-authoritative actor is handed back: the
actor is enrolled if untracked and re-expressed under the host's id. `[V]`
+**A key on the wire resolves through `coop/props/prop_key_index`**, which holds every live keyed prop
+of the current world: membership from the engine's own birth and death notifications, the key from
+the four bodies that write it (`init`, `loadData`, the `setKey` event, and a `getKey` after another
+object wrote the key). A wire read that misses first catches the object index up, so its miss means
+no such prop here; a read that cannot answer yet (no session seed, no current world, a birth still
+loading) says so instead, and the host's uniqueness check at enrolment reads the index as it stands. The tracked set the census keeps by key is a different thing, what this peer has
+enrolled or tracked, and only the join sweep and the save-transfer key diff count it. `[V]`
+
**Tracking is gated on hosting, never on a peer being connected.** Every catch and enrol below
runs whenever a session exists with the host role: an event actor spawned while the host is alone
must still enrol, or the connect snapshot has nothing to send when someone joins. Only wire sends
@@ -43,6 +51,7 @@ are peer-gated. `[V]`
| the kerfur | conversion verbs inside the Blueprint | the script-body gate on the verbs: refused on a client, converged at the return on the host; whom it serves, its player-0 reads answered on the host with the puppet of the client whose command it runs (`kerfur_command`, `served_player`) | one host kerfur id across both forms |
| the plain kerfur (the Kerfus) | a keyed prop with a brain; on and off are a field, not a second form | the prop lanes for identity and the record at birth; while on, the host's prop drive for its pose; KerfusState for on, charging and energy; whom it follows, its player-0 reads answered on the host with the puppet of the client that turned it on (`kerfus_follow`, `served_player`) | its prop key and host eid |
| a deployed hook or rope | the fire input, inside the Blueprint | the thrower polls its own `activeHook` field | `(owner slot, sequence)` while the thrower holds it, a host-minted save key once anchored |
+| a remote player's puppet (`mainPlayer_C`, unpossessed) | `RemotePlayer::Spawn` -> `engine::SpawnActor` | registered between its deferred spawn and its `FinishSpawning`, by the before-finish callback; unregistered after its destroy (`DestroySlot`) | the peer's slot in `players::Registry` |
| status effects (effect_C) | each peer's own, from its gamemode's addEffect; never sent | -- | none: a per-process actor; at a join the host's effect records are reset out of the joiner's save at the join |
## Keyed props
@@ -65,12 +74,14 @@ are peer-gated. `[V]`
changes, so an adoption binds a wire identity to an actor that is about to vanish on somebody
else's schedule and the prop the birth named never appears there. The hand axis has one owner
and both walks that must respect it -- the census outward, this scan inward -- ask it. `[V]`
-- **Key uniqueness.** The game's own saves ship duplicate keys, a clone family of dozens of
- trash-bit piles on one key among them, and every identity layer assumes uniqueness. The host
- is the key authority: at enrolment, a keyed actor whose key another live actor already carries
- is re-keyed with a fresh random key, once, and the game re-saves the live keys so the fix bakes
- into the host's save and every transferred world. A dead incumbent's recreate inherits its
- identity. Clients never re-key. `[V]`
+- **Key uniqueness.** The level's own content carries duplicate keys (present before any save record
+ loads, at the same keys and places every load), a clone family of dozens of
+ trash-bit piles on one key among them, and a New Game saves them as they are; every identity
+ layer assumes uniqueness. The host is the key authority: at enrolment, a keyed actor whose key
+ another live actor already carries is re-keyed with a fresh random key, once. The fix bakes into
+ the host's save and every transferred world for an actor that lives to a save; an actor
+ that a save's load destroys and re-creates from its records takes its throwaway key with it.
+ A dead incumbent's recreate inherits its identity. Clients never re-key. `[V]`
- **A client never authors a save-loaded prop.** Three intent doors route around it: the drop
intent for a parked key placed after a pickup, the whitelisted births (a reel, a module, a
drive, a container extract) the client cannot avoid, and a birth a PLAYER's own spawn verb
@@ -93,7 +104,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
@@ -101,7 +120,7 @@ are peer-gated. `[V]`
born through the birth channels with its content on the save-record lane above. The slot itself
is state on the wire, so the destroy is not the only thing that crosses: the host owns
every device's slot, a peer reports the outcome of one its own game changed, and the host's
- re-publish is the answer ([devices.md](devices.md)). `[V]` **An ejected disc is born where the box that
+ re-publish is the answer ([base-computer.md](base-computer.md)). `[V]` **An ejected disc is born where the box that
ejected it turned its own collision off, and the box on the other machine never ran that eject**,
so it would take the disc through the same overlap the insert uses. A disc is therefore marked IN
TRANSIT at its own spawn and no device swallows one on any peer while the mark holds. The disc
@@ -157,7 +176,8 @@ graph spawns are caught at the native seam and enrolled at the next pose tick. A
character on a joiner is adopted by class after a deferred poll, never by key, because the
kerfur's key is random per peer. Pose is a batch, rotated fair-share past a batch's worth. A
destroy is caught before it runs; a self-destroy inside the Blueprint is found dead by the pose
-walk and retired. `[V]`
+walk and retired. `[V]` A client's mirror of a character is stamped as the native spawner (`spawner_stamp`): the
+constants a class's own spawner writes between the two spawn halves are written there too. `[?]`
The wisp swarm's spawns are `EX_CallMath` from the swarm trigger's graph and are caught at the
native seam gated by the calling class; the ambient sky wisps are host-authored, their spawner
@@ -165,16 +185,56 @@ cancelled on clients and their colour variants allowlisted. A wisp mirror keeps
since the class's tick is per-viewer cosmetic, and its landing edge is replayed from the stream;
its self-destroy is retired by the pose walk. `[V]`
+**A creature's event count.** A creature that calls `lib_C::setEvent` (the krampus, the orb, the ariral, the
+kerfur Omega among them) moves the gamemode's event count, which drives a peer's threat music, its sleep refusal and its
+save gate. Its mirror's own calls are refused once the relay binds it; the host's count for that creature crosses whole
+by its element id and is applied to the mirror's registration, after the mirror's own registrations from before its
+install are taken back, and taken back again at its end (`coop/world/creature_event_relay`). The killer wisp, the
+fossilhound, the antibreather and the egg compute their event from the local camera, render state or player 0 and keep
+their own per peer. `[RD]`
+
+**A parked creature's brain.** A mirror of the zombie family or the fossilhound runs none of its Blueprint's event
+graph. It is marked when it is born (between the two halves of its spawn, or at the bind of a client's own
+save-loaded twin), and the graph watches (`coop/creatures/npc_mirror_brain`) refuse every entry of a marked mirror
+but the few a table names: the footstep notify, and the fossilhound's per-viewer loop. What its BeginPlay set up
+for the eye is done by our code after the install: the zombie's animation instance and walk blend, the fossilhound's
+mesh parent, animation instance and owner. Lost on a mirror: the attack animation and sounds, the fossilhound's hit
+sound and its hidden-then-dig-out. A client's own creature of these classes is never marked and runs whole. The mark
+is the actor's slot and the slot's serial, so a recycled address is never a mirror, and it ends with the mirror.
+`[RD]`; the refusals `[?]` until a run reads them.
+
+**A client's hit on a parked creature.** A client's swing ends in the struck mirror's `addDamage` with the player as the
+damager. The call is watched (`coop/creatures/creature_hit_intent`): the client plays the zombie family's hit feedback
+at once (the fossilhound has none), sends the creature's element id with the claimed damage, the point struck and the
+impact, and cancels the call. The host judges the swing against the sender's held item and its reach data, its reach
+from the eye the host holds for it, that the sender's newest streamed aim faces the point (within 60 degrees, in 3-D; eye
+and aim are captured when the intent arrives), the sight to the point and the point's distance from the creature,
+cuts the damage to the item's most and runs the creature's own `addDamage` with a null damager; health, death and
+the destroy are the host's, and nothing is answered. The swinger's liveness is judged by the health its pose stream
+carries (a dead player's pose says 0, and the puppet's `dead` flag is never set by a peer's death): at most 0 refuses
+the swing `dead`. A zombie the host kills this way leaves its ragdoll body on the host alone: the body's class has no
+lane (owed). A ranged tool's hit is not a swing and is refused. `[RD]`
+
## Event actors
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]`
+**The Bad Sun** is the event actor no mirror expresses: every peer runs its own `badSun_C` copy and
+its harm is local, so nothing crosses as a pose. Identity: none, a copy is found by class. Birth:
+the save (a joiner's copy comes with the join blob) or `Spawn Bad Sun`. Existence is the host's,
+through `coop/world/weather_badsun`: the host's spawn and its copy's `remove()` send an absolute
+present or absent, a client's own spawn is refused at the script-body gate, and a joiner is seeded
+at its world-ready. A client keeps exactly one copy. End: each copy's own fade; the host's absent
+destroys an inactive copy at once and leaves an active one to end by its own clock. The wire half is
+`[?]` until the lane's drill runs; the Blueprint routes are `[RD]`.
+
**The materialisation window.** A mirror's component delegates bind during its begin-play, inside
the finish-spawning call, while its row is installed only after that call returns; so for a
moment a row lookup answers "not a mirror" for an actor that is. The window is published as a
@@ -242,7 +302,7 @@ registry recycled that id. `[V]`
`[V]`
- **A prop a hook is tied to.** The constraint exists on the HOST and nowhere else, whichever
peer fired the hook (`coop/items/hook_constraint`): a client's game breaks every tie a hook of
- the lane's classes builds on it, at the native seam the build funnels through, and the host ties
+ the lane's classes builds on it, in the frame that built it (a post watch on `makeAttachments`, `[V]` on the rig: `hookdrag` on `72742a09`, every role), and the host ties
a client's hook on its own mirror of it, against that client's puppet, from the bite the owner's
state names -- the actor's key and element id, the component, the head in the component's frame,
and whether the hook flew before it bit, which is what wakes a frozen prop. Every prop under a
@@ -277,6 +337,7 @@ Every place two seams can express one actor, and what deduplicates it:
| a joiner's save-loaded anchored hook and the host's replay of the same hook | the host mints the save key, and the joiner skips a key the game already resolves `[V]` |
| a level-placed hook variant, which attaches itself on every peer | it is not in the lane's class table, and the owner's own field can never name it `[V]` |
| a conversion's fresh prop and the generic spawn seams | the captured form is tracked and its broadcast suppressed `[V]` |
+| a joiner's save-loaded Bad Sun copy and the seed's present, or the host's live present | the apply counts the live copies first: one copy is left alone, none is spawned, two are logged and not repaired `[V]` (the drill's rejoin) |
| a client's menu-spawned prop and the host's copy of it, spawned from the intent | the client's own actor IS the mirror: the host sets the client's key, and the echo resolves it back to that actor and converges its pose instead of minting a second `[V]` |
## Needs a probe
diff --git a/docs/coop-sync-doctrine.md b/docs/coop-sync-doctrine.md
index b9d3bf0fe..933017a3e 100644
--- a/docs/coop-sync-doctrine.md
+++ b/docs/coop-sync-doctrine.md
@@ -66,7 +66,7 @@ Read the dispatch map first, then pick the cheapest seam that actually fires:
3. The native function seam (`ue_wrap/core/ufunction_hook`): calls into native functions on
every route, because every route funnels through the function's thunk. After the call it
reads the result and may overwrite it; its PRE form reads the parameters before the call and
- can refuse it, for a native with no return value and no out or reference parameter
+ can refuse it, for a native with no return value and no out parameter or non-const reference
(`InstallPreHook`). Any other native that must be cancelled needs a detour of the C++
function itself.
4. Per-site reconcile, the last resort: let the verb run, snapshot and diff the observable state,
diff --git a/docs/credits.md b/docs/credits.md
index 993a00710..59b798566 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 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 |
@@ -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 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.
After a quit-to-menu the dying world and its ragdolled `mainPlayer_C` stay in
diff --git a/docs/deployables.md b/docs/deployables.md
index fa6273a04..384e70748 100644
--- a/docs/deployables.md
+++ b/docs/deployables.md
@@ -38,7 +38,7 @@ the hook lane did not have in front of it:
- **The physics constraint exists on the host and nowhere else.** The deployed hook's tie is a
PhysX constraint the game builds on whichever machine runs its attach verbs, and a constraint
pulling a host-owned prop on a client is that client moving shared state the host never sees.
- So a client breaks every tie a hook builds on it, at the native seam the build funnels through,
+ So a client breaks every tie a hook builds on it, in the frame that built it (a post watch on `makeAttachments`, `[V]` on the rig: `hookdrag` on `72742a09`, every role),
and the host builds a client's hook its real tie on the mirror it holds of that hook, against
that client's puppet, from the bite the owner's state names. The client's own hook keeps its
pull on the client's own player, which is a velocity write in the tick and not the constraint;
diff --git a/docs/devices.md b/docs/devices.md
index 912b23f11..b45c70900 100644
--- a/docs/devices.md
+++ b/docs/devices.md
@@ -1,12 +1,13 @@
-# Devices and the economy
+# Devices
## Purpose
The keyed things around the base that are not the workstation: doors and their keypads, the
-light switches and the light groups, the garage, the appliances, the lockers, the power panel,
-the wind turbine, the windows and the grime, the delivery drone, and the economy the drone
-serves: the shared balance, the shop orders, and the coin gun. Who owns each, how a peer's press
-reaches the others, and where the group's money can still be lost.
+light switches and the light groups, the garage, the appliances, the lockers, the security cameras,
+the power panel, the wind turbine, the windows and the grime, the delivery drone, and the signal
+servers. Who owns each and how a peer's press reaches the others. The laptop and the economy the
+drone serves (the balance, the shop orders, the coin gun) are in
+[base-computer.md](base-computer.md).
## How it works
@@ -17,7 +18,8 @@ Every open-or-closed, on-or-off device rides one replication engine
a key-to-actor index that heals itself, per-key dedup, a deferred apply with a throttled retry for
an instance that has not streamed in yet, echo suppression, and the connect snapshot. A device
family is an adapter over its engine wrapper (`ue_wrap/devices/`), a few lines each: doors, light
-switches, light groups, container lids, the garage, the appliances, the lockers, the oven's repair.
+switches, light groups, container lids, the garage, the appliances, the lockers, the oven's repair,
+the security cameras.
Each channel is sent at the verbs that write its state, which the script-body gate watches: every
live writer of a door's open state goes through `doorOpen` or `doorClose`, and of a light group's
@@ -54,8 +56,17 @@ writer of Open past the load, and the appliance lane an appliance's bool at its
(a faucet's, sink's, shower's, oven's and tape unit's toggle) or a server box's `visual` (the kerfur
Omega's call), the box lane a locker's `opened` at its `open` (its toggle's, a murder kerfur's) and the drone
console's at its `actionOptionIndex`, and the container lane a lid's `opened` at its `open` or `close`
-(which its setup, grab, damage, padlock and resting swing, and a cremator's door, call), and the oven
-lane an oven's `fixed` at its `fix()`, the repair widget's last step, which goes one way: a receiver
+(which its setup, grab, damage, padlock and resting swing, and a cremator's door, call). Swinger
+hinges also use `mouseAssign(FVector2D)` while a player drags the handle. That body converts the
+mouse sample into torque on `PhysicsConstraint` (`prop_swinger` graph, block 386); its post-gate
+observer sends each sample as `SwingerMotion` and receivers replay it through their own
+`mouseAssign` body. On release, the same channel sends the hinge's final rotation and linear and
+angular velocity and runs the swinger's `dropped` cleanup remotely. This covers the smooth, physical
+movement during the drag and corrects the receiving hinge at release for microwave, oven and other
+`prop_swinger_C` descendants. The sample stream is ordered and relayed, while the game still
+simulates each peer's physics locally, so matching hinge motion needs a two-peer in-game check. A
+joiner receives the channel's saved open/closed state, not the exact in-flight hinge angle.
+The oven lane an oven's `fixed` at its `fix()`, the repair widget's last step, which goes one way: a receiver
writes it and repaints on 1, `fix()`'s work without its screen, and refuses 0.
A client's own press, hit or pry of a door never runs on its copy either: the
script-body gate refuses the door's entry verb there and sends it to the host
@@ -74,6 +85,55 @@ puppet counts in that list exactly when the client's own player counts in the cl
it. A device with no auto-revert (the garage, an appliance, an oven's repair, a locker, a lid) is
symmetric: any peer's edge is the state.
+### Security cameras
+
+A camera has two saved bits, each its own lane keyed by the lane's key for the camera (its portable
+identity where it has one, else its prop key): `active` (the LED, the click, the laptop's camera
+list and capture gate) and `isLight` (the spotlight, flipped from the laptop's camera panel). Both
+are the host's. Besides a player's use, a wisp, the gray events, the cursed camera's night roll and
+a save's load write `active`, and most of those are host-owned world systems, so the host's lanes
+read both bits after a camera's `actionOptionIndex`, `upd` and `updState` and after the cursed
+camera's tick, and send a change to every peer. A client renders what it is sent: the apply writes
+the bit, then runs `updState` when the write turns a camera other than the cursed one on, which
+plays the click, else `upd`, then repaints a laptop camera panel that has the camera selected. A
+joiner hears one click for a camera the host has on that its save had off, and none for one that is
+already on.
+
+A client's own use of a camera and its press of the laptop's camera-light button are refused at
+their bodies and sent to the host (`coop/interactables/camera_toggle_intent`). The host checks the
+sender's reach, of the camera for the use and of any device showing the laptop interface (the base
+laptop or a portable PC) for the light, and sets the bit to the value the sender wanted, so two
+presses that race collapse to one change. A bit already at that value changes nothing and is sent
+again to that sender alone, so a client whose copy diverged converges and its button is never dead.
+The host's own open laptop panel is repainted after it performs a request. The cursed camera's tick
+is refused on a client: the host rolls it. A camera whose key is empty, or the key its class carries
+by default, is addressed by no lane: a client's press on it is refused and sends nothing, and a
+cursed one's tick stays that peer's own. A client's copy of a wisp still switches that client's own
+cameras off until the creature mirror stops running the wisp's world writes `[?]`.
+
+### Coffee maker and vending machine
+
+The vending machine and coffee maker use their native `actionOptionIndex` body, but a client
+does not run that body on its own copy. `coop/interactables/coffee_intent` sends the keyed device
+and selected action to the host; the host resolves the live actor, checks the sender's reach, and
+runs the same Blueprint action with that sender's puppet. The request carries no price or credit
+amount. The game's machine reads and debits the host's shared points, which `balance_sync` then
+mirrors to every client. This keeps machine credit, the common balance and the outcome under one
+authority.
+
+After the action, the host sends the machine's vending balance/processing or maker cup/coffee/
+readiness/power flags. Coffee cups spawned by the vending machine use the ordinary host prop birth
+and snapshot lanes; their `first` flag and inherited `uses` remaining-sip count are sent as host
+coffee-device state and included in the join snapshot, so a client sees the same count after a drink. An existing
+prop's `getData`/`loadData` record carries the coffee bag's `amounts` field. `SnapshotToSlot` seeds
+vending machines, coffee makers and cups at world-ready. The dump identifies
+these fields and the action entry points (`prop_coffeevending_C`, `prop_coffeeMaker_C`,
+`prop_coffeeBag_C`). The coffee maker's cup birth is re-expressed through the
+keyed prop lane after its Blueprint action, and a client's final `eaten` edge now runs on the host
+so the empty cup birth and full-cup destroy reach every peer. The cup's remaining sips are host state.
+The `used` coffee-power increase stays local to the drinking player. Two-peer play still needs to
+confirm cup handoff/drinking and bag-record updates across both installations `[RD]`.
+
### What is inside a container
A container's contents are not on the container. Every one of them reads from a single global
@@ -108,6 +168,24 @@ 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 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, 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 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
+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
@@ -235,9 +313,9 @@ judged within reach of the part it acts on, in the player's order, at a few a se
The delivery drone is one host-simulated actor: its flight is a fragile per-tick integrator not
worth reproducing, so the host streams its transform while it moves (it glides on after its active
-flag drops) and its state the moment it changes, the client suppresses
-the drone's own tick and drives the streamed transform through an interpolation window, and the
-cargo it drops rides the ordinary prop lanes (`coop/interactables/drone_sync`). The drone's sale
+flag drops) and streams its state at change edges, including the five-minute or indefinite leave
+mode. The client suppresses its own drone tick and drives the streamed transform through an
+interpolation window; dropped cargo rides the ordinary prop lanes (`coop/interactables/drone_sync`). The drone's sale
runs on the host only, which is what makes selling into it the one economy path that credits the
group correctly.
@@ -252,163 +330,15 @@ stands at, re-tests its own copy of the lid, then runs the same verb, so the fli
machine that owns it and arrives on the stream that already carries it
(`coop/interactables/drone_call_intent`). A press that reaches the host before it holds the sender's
body, from a joiner who presses at once, waits for the body rather than being refused, as the
-door-verb, keypad and container lanes wait. The leave-timer face has no lane and stays local.
-
-### The balance
-
-The host owns the balance. It polls the points field every tick, which catches every writer, and
-broadcasts the absolute value on change and to a joiner; a client writes the host's value directly.
-The wire is one-way. A client-to-host balance delta once existed with no bound, so any peer could
-set the group's money to anything; it was retired whole rather than clamped
-(`coop/world/balance_sync`). A client's own earnings are therefore not shared unless a lane
-carries them as an intent.
-
-### The upgrades
-
-The eighteen upgrade levels are one struct on the save, so they used to ride only the transferred
-save and diverged in silence from the moment a level was bought. The host now polls the struct and
-broadcasts it whole on any change, which catches every writer: the panel, the physical racks, the
-transformer prop. A client applies what it is sent and repaints the rows it has open.
-
-A purchase is an intent. A client's button is refused at the script-body gate, so it never debits
-itself, and the host re-derives the price and the bounds from its own table and its own level,
-charges the shared balance and republishes. The rows have no identity of their own to name, so
-holding the laptop claim is the sender's reach, and the index must be one of the fifteen rows that
-buy a level. The host asks exactly what the button asks, including the game's own asymmetry: the
-balance is tested against the row's unaccumulated price and charged the accumulated one.
-
-### The laptop's inbox
-
-The laptop's messages -- the hash-collection task mails, the scientist and alien replies, the
-mails an event or a caught signal produces -- are the same on every machine, and deleting one
-deletes it for everyone. Every producer in the game funnels through one Blueprint-internal
-function into the save's email array, and calling that same function on a receiver reproduces the
-whole arrival at once: the stored row, the list entry, the ding at the physical laptop and the
-tab highlight, with the date re-stamped from the synced clock.
-
-Each peer keeps a shadow of the array and diffs it once a second. The array only ever grows at
-the tail, so the diff is positional. A new row is broadcast by the host -- see below -- and a
-removed row is broadcast by whoever deleted it, as the row's content HASH and never its index,
-because a producer writes its own row before that row reaches anyone and two peers therefore hold
-the same messages in different orders.
-
-Writing a mail is the host's alone: nothing in the game authors a mail from a player action, so a
-client that started producing them could only be a diverged simulation writing into everyone's
-permanent inbox, and a client's append is dropped at the host rather than relayed. Deleting is
-symmetric, because the only thing in the game that removes a mail is a player pressing the row's
-delete button (`coop/world/email_sync`).
-
-### Shop orders
-
-A client's laptop order is entirely local to its machine: the game's order function queues it in
-the client's own save and hands it to the client's own mirror drone. So on a client the order function's
-queueing (`addOrderCart`) and its send to the drone (`sendShop`) are refused at the script-body gate, and
-the order goes to the host as an intent naming each item's shop row and nothing else, read from the
-function's own parameter at its entry; the host prices the row from its own
-store table, checks its own balance, rolls its own delivery time, commits through the game's own
-order function and charges (`coop/items/order_sync`). An order a world event makes on a client (the
-daily delivery, a gift) is not sent: the host's copy of the event makes it. A refused order comes back with a reason, and the refused items
-are put back in the client's cart, because the game's own affordability gate runs before the cart
-is cleared while the refusal arrives after. This is the reference intent lane: the intent used to
-carry a client-chosen price, and every client shopped free.
-
-The intent names a shop ROW, not an object class, because a class cannot name an item: the game's
-473 shop rows map onto 368 distinct classes, and `prop_C` alone is shared by 50 of them, so 112
-rows have no unique class. The row name is the shop's real identity, and the game stamps it into
-each generated store entry, so a forwarded order already carries it.
-
-The host's price comes from the game's own `list_store` table, read two independent ways -- a walk
-over the row map, and a fully reflected column read that needs no layout knowledge. They are
-compared, and a single disagreement invalidates the whole catalog: the host then refuses client
-orders outright rather than charge a number it cannot vouch for. Setting
-`VOTVCOOP_STORE_CATALOG_BREAK=1` makes the walk read the wrong field on purpose, so that refusal
-can be seen firing before it is trusted (`ue_wrap/world/store_catalog`).
-
-The delivery queue is the host's. Each change to it -- an order queued, the delivered one taken off --
-goes to every client as the laptop's own queue functions run, and a client's queue follows through
-the same functions (`coop/items/order_queue_sync`). A shop item travels by its row; an item a world
-event built outside the shop has no row and travels by its class, and by the list_props name a generic
-prop carries in its asProp (the daily delivery's reel case), rebuilt in the one shape every such builder
-makes. A change waits, in its place, while the client's laptop, its shop catalog or the engine's name
-conversion is not ready yet; an item the client can never build is left out, down to an order of no
-items that still keeps its place, so the host's next delivery takes the same order off both queues; and
-a change counts as applied only when the queue moved by exactly one.
-
-### The coin gun
-
-Shooting a prop with the coin gun sells it: the game destroys the prop and mints coins whose
-material is their denomination. A client's shot is uncancellable, so the lane captures the
-client's own coins at their birth and releases or destroys them at the next barrier, sends a sale
-intent naming the prop by key ahead of the client's ordinary destroy on the same lane, and the
-host resolves the prop in its own world, prices it from its own copy, mints through the gun's own
-sell function and destroys the sold prop itself; the coins are host-owned world actors that the
-event-actor mirror carries, and whoever's body trips one on the host credits the host, a client's
-puppet included (`coop/items/coingun_sync`). The client is told the result.
-
-### The floppy slot
-
-A laptop and a signal server hold a disc the same way: inserting one moves its type, its remaining
-writes, its data rows and the JSON of its whole save struct into four fields of the device and
-destroys the actor, and ejecting one spawns the disc back from those fields. The four are one
-concept with one owner, and they are state, not an event: the host holds every device's slot, each
-peer polls its own devices once a second behind a digest that reads the raw field bytes, and a peer
-whose own game changed a slot sends the host the outcome as a claim. The host applies it and answers
-with the canonical, which is also the acknowledgement (`coop/interactables/floppy_slot_sync`).
-
-The laptop also edits the files of the disc it holds, which its file quad carries as edit scripts
-(`coop/interactables/laptop_buffer_sync`). So the laptop's slot is watched for which disc it holds
-alone: the rows and the writes travel with a change of that and are the quad's between two. A
-canonical naming the disc a laptop already holds leaves its files alone, and the slot lane primes
-the quad at each change it takes -- a claim sent, a publish, a canonical written -- so an edit made
-after it is the quad's to send, even one made before the host answered the claim. The host counts
-each change of the laptop's disc; the quad's batches and canonicals carry that count on the slot's
-lane, a batch made on another disc is answered with the canonical instead of applied, a canonical of
-another disc is dropped, and neither end applies or sends one while its own change of the disc waits
-for the other.
-
-Emptying a slot writes only what the device's own eject writes -- the type and the rows. The eject
-runs in two phases: it clears those two, and about a second later, when the carrier's timeline
-finishes, the deferred spawn reads the remaining writes and the save JSON to rebuild the disc.
-Anything that zeroes those in between hands the player back a blank disc under a new identity.
-
-A disc a peer ejects reaches the others through the ordinary birth channel: a client's own fresh
-prop spawn is not broadcast, so its device's eject is reported to the host, which authors the disc
-and broadcasts it like any other world prop. Three other lineages travel that way, and all three
-are born into a hand and spawn inert on the host until the holder's pose stream drives them; a
-disc is not held by anyone, so it falls on the host instead. Either way the disc's files and
-remaining writes travel as its save record behind the birth; a disc the host ejects gets its record
-from the host's finish-spawning drain, since the eject fills the disc in after the finish call
-([props.md](props.md)).
-
-The laptop's slot crosses as one blob, and the save JSON carries the rows a second time, so a disc
-with a few dozen rows is kilobytes. The blob is held to the ceiling the server slot uses and refused
-whole past it, never cut: a cut JSON is one the receiver's own eject cannot parse, and the disc it
-hands back comes out blank under a new key. The laptop keeps at most 31 rows in the slot itself
-(`ui_laptop.updFloppy` trims `floppyData`); the JSON keeps all of them.
-
-A device takes a disc by two entries, and only one of them is a player. Pressing E with a disc in
-hand is deliberate, happens on one machine, and the slot carries its outcome. The other is the
-hitbox reporting whatever touches the slot -- and an eject spawns the disc INSIDE the box it came
-out of, which the Blueprint handles by turning that device's own hitbox off for a moment. In single player
-the only birth that can land in a slot is that eject, so guarding the one box is enough; in coop
-the disc is also born on the other machine, in a box whose hitbox nobody turned off and where
-nobody ejected anything. So the rule sits on the disc rather than the box: a disc is in transit for
-a moment after it materialises, and no device swallows one in transit, on any peer. The window is
-anchored at the disc's own appearance on each machine, so both peers run one rule against one local
-event and neither waits on a message; when it lapses, the native rule simply resumes.
-
-The window is one number for every device, because the mark is on the disc and a disc does not know
-which slot it came out of. It has to clear the frame or two the hitbox entry needs to fire, and it
-has to end before the device that ejected re-enables its own hitbox -- re-enabling a collider
-re-reports every body already inside it, which is how the game re-takes a disc still sitting in the
-slot, and that report comes once. The two devices disagree on the pause: a signal server waits a
-second, a laptop half of one. The window is set under the shorter of the two, so neither device's
-own re-take is touched and an ejecting peer behaves exactly as it does in single player.
-
-The hitbox also takes a disc another peer is holding. That peer's pose drives this machine's copy
-of the disc, and bringing it to the slot brings the copy into this machine's copy of the slot, so
-the watching peer would insert it too and claim a second insert of one disc. The entry refuses a
-disc under another peer's hold on every peer; the holder's own insert is the one the slot carries.
+door-verb, keypad and container lanes wait. A client toggles the leave-time face through the same
+intent lane; the host applies the toggle to its current setting and the next state update carries it
+back (`coop/interactables/drone_sync`, `coop/interactables/drone_call_intent`).
+
+### The computer and the economy
+
+The base laptop (its tab and inbox), the shared balance, the upgrade levels, the shop orders, the
+coin gun and the floppy slot are in [base-computer.md](base-computer.md). They ride the engine
+above and the drone's host-only sale.
### The signal servers
@@ -419,7 +349,14 @@ 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
+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
@@ -444,13 +381,14 @@ removed from its author's hand -- and a joiner gets every box's count at its wor
| State | Owner | Shape |
|---|---|---|
| a door, a light group | the host | each is sent at its own verbs; a client's own door verb is an intent the host runs, and its own group writes are refused |
+| a security camera's `active` and `isLight` | the host | each is sent after the camera's use, `upd` and `updState` and the cursed camera's tick; a client's use and light button are intents the host runs |
| a light switch, a lid, the garage, an appliance, an oven's repair, a locker | any peer | symmetric state edges, relayed |
| the power panel, the generators | the host | a client's press, repair, service, upgrade install and hit are ops the host takes from a player within reach; the host sends the breakers after its panel's apply and every generator's row after its verbs |
| a keypad | the host | its verbs replayed on every client and its settled state after each chain; a client's own entries are an intent the host runs |
| the coordinate towers | the host | a row per tower whenever one changes; a client's presses are intents the host runs, its fuse pulls and inserts claims the host takes or refuses |
| the turbine | the host | six floats a second |
| a window, the grime | any peer, minimum wins | monotone decreases |
-| the drone | the host | a transform stream; the client's tick suppressed |
+| the drone | the host | a transform stream and canonical flight/leave flags; the client's tick suppressed |
| the balance | the host | one-way, absolute, on change |
| the upgrade levels | the host | the struct whole, on change; a purchase is an intent |
| an order | the client names the row; the host performs and prices | an intent |
@@ -464,6 +402,8 @@ removed from its author's hand -- and a joiner gets every box's count at its wor
| `DoorState`, `LightGroupState` | the host to all | a key and a state |
| `LightState`, `ContainerState`, `GarageDoorState`, `ApplianceState`, `LockerDoorState` | each peer, relayed | a key and a state |
| `DoorVerbIntent` | a client to the host | a door's key, the verb, a hit's damage |
+| `CameraActiveState`, `CameraLightState` | the host to all | a camera's key and one bit |
+| `CameraToggleIntent` | a client to the host | a camera's key, which bit, the value wanted |
| `KeypadState` | the host to all | a verb the host's keypad ran, or the state a chain settled on: the buffer, the verdict, the set-new-code mode, the password, and whether the chain handed the verdict on |
| `KeypadIntent` | a client to the host | a keypad's key and the entry: a digit, a submit, a cancel, a keycard's verdict, a reset |
| `PowerControlState` | a client to the host; the host to all | the breakers a press flipped; the breakers, the lockout, a lever's click and each slot's last press taken |
@@ -486,49 +426,32 @@ Every channel snapshots the full state of every indexed instance to a joiner at
open and closed alike, because the save the joiner loaded, the host's captured at its join, holds
neither a state the game does not save (a server box's `active`, a sink's tap) nor one changed while
the joiner loaded; an oven's repair is in that save, and the snapshot says it again; the keypads, the power masks, the turbine, the windows, the grime and the
-drone's pose are sent the same way, and the balance is sent at connect. The delivery queue goes to a
-joiner as a reset and then every queued order, and the joiner ignores the queue's changes until that
-reset arrives.
+drone's pose are sent the same way.
The coordinate towers go to a joiner at the same edge, every tower's row. Its own world's load
would have scrambled again any tower that was saved broken; that roll is refused, so the row is the
state it shows, and the first rows a world takes play no sound.
-Every device's floppy slot goes to a joiner at the same edge, an empty one as much as a full one:
-the joiner's world came from the host's save file, which coop stops the game refreshing, so it knows
-nothing the host has done since. The laptop's file quad follows the laptop's slot on the same lane
-and under the same count, so the joiner takes the disc before its files, and a set the transport
-refused is sent again with the quad behind it.
-
-The inbox rides the joiner's save transfer, so it arrives whole. A mail written during the 30 to
-60 seconds the joiner spends loading is in neither that save nor any later diff, so the host
-captures the array at the instant it hands over the save and sends the difference on the joiner's
-ready edge -- once per slot. A mail that arrives before the joiner's world can take it is parked
-in arrival order and applied once the array settles, rather than dropped on the spot; a row the
-settled world still refuses after thirty attempts is malformed for that world and is dropped with
-an error line.
-
## Known limits
| Limit | Evidence |
|---|---|
| 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 |
## Code map
| Concept | Files |
|---|---|
| the engine and the adapters | `coop/interactables/interactable_channel.h`, `coop/interactables/interactable_sync`, `coop/interactables/door_verb_intent`, `coop/interactables/door_state_verbs`, `coop/interactables/toggle_verbs`, `coop/interactables/verb_lanes`, `ue_wrap/devices/door`, `ue_wrap/devices/door_box`, `ue_wrap/devices/lightswitch`, `ue_wrap/devices/garage`, `ue_wrap/devices/appliance` |
+| cords and sockets | `coop/interactables/cord_sync`, `ue_wrap/devices/cord` |
+| security cameras | `coop/interactables/camera_toggle_intent`, `coop/interactables/toggle_verbs`, `coop/interactables/interactable_sync`, `ue_wrap/devices/security_camera` |
| keypads | `coop/interactables/keypad_sync`, `ue_wrap/devices/passwordlock` |
| power, turbine, windows, grime | `coop/world/power_panel`, `coop/world/power_grid`, `ue_wrap/devices/generator`, `coop/interactables/turbine_sync`, `coop/interactables/window_sync`, `coop/interactables/grime_sync`, `ue_wrap/devices/power_control`, `ue_wrap/devices/windturbine`, `ue_wrap/devices/base_window`, `ue_wrap/devices/grime` |
| the coordinate towers | `coop/world/coord_tower_rows`, `coop/world/coord_tower_ops`, `ue_wrap/desk/coord_tower` |
| the drone | `coop/interactables/drone_sync`, `coop/interactables/drone_call_intent`, `ue_wrap/devices/drone`, `ue_wrap/devices/drone_console` |
-| the floppy slot | `coop/interactables/floppy_slot_sync`, `ue_wrap/devices/floppy_slot`, `ue_wrap/devices/serverbox`, `ue_wrap/devices/laptop` |
-| the inbox | `coop/world/email_sync`, `ue_wrap/world/email`, `coop/session/join_seed` |
-| the economy | `coop/world/balance_sync`, `coop/items/order_sync`, `coop/items/coingun_sync`, `coop/interactables/upgrade_sync`, `ue_wrap/world/economy`, `ue_wrap/world/order_economy`, `ue_wrap/world/store_catalog`, `ue_wrap/world/upgrades` |
+| the signal servers | `coop/interactables/serverbox_sync`, `coop/interactables/server_upgrade_sync`, `ue_wrap/devices/serverbox` |
| identity | `coop/element/portable_identity` |
-| tests and probes | `coop/dev/order_selftest`, `coop/dev/container_selftest`, `coop/dev/door_drill`, `coop/dev/lightswitch_probe`, `coop/dev/drone_probe`, `coop/dev/drone_call_drill`, `coop/dev/light_group_census`, `coop/dev/floppy_selftest`, `coop/dev/tower_drill` |
+| tests and probes | `coop/dev/container_selftest`, `coop/dev/door_drill`, `coop/dev/lightswitch_probe`, `coop/dev/drone_probe`, `coop/dev/drone_call_drill`, `coop/dev/light_group_census`, `coop/dev/tower_drill` |
diff --git a/docs/events-and-weather.md b/docs/events-and-weather.md
index b5e2fdaac..282866324 100644
--- a/docs/events-and-weather.md
+++ b/docs/events-and-weather.md
@@ -61,7 +61,17 @@ strike is a transient actor; the host observes its spawn and sends the location
toggles at noon on every peer -- a living one ends, else a one-percent roll starts one --
dispatched inside the Blueprint, where the script-body gate still sees the toggle: the host sends
its red sky as each toggle leaves it, and a client refuses its own and runs the host's through
-the same toggle (`coop/world/weather_redsky`). The black fog and the rolling fog the clock rolls
+the same toggle (`coop/world/weather_redsky`). The Bad Sun is a story event actor too, but every
+peer runs its own copy by the shared clock and the harm is local: its widget traces from the local
+camera and hurts the local player. The host owns whether a copy exists -- it sends its state as
+`Spawn Bad Sun` returns and as its copy's own `remove()` returns (the midnight roll stays the host's,
+and now crosses). A client's own `Spawn Bad Sun` is refused at the script-body gate, and a client
+keeps exactly one copy: the host's present spawns one if none lives, and the host's absent destroys
+an inactive copy at once and leaves an active one to fade by its own clock
+(`coop/world/weather_badsun`). The clock half is `[V]` (a client's own copy activates at 07:00); the
+wire half is `[V]` (the lane's drill: a spawn, a refused client spawn, the end, a rejoin and a joiner's
+stale copy, each with its red), the day cycle's midnight route `[V]`, and the cheat menu's `[RD]` (its
+handler needs a Sandbox or flying world). The black fog and the rolling fog the clock rolls
funnel through the engine's finish-spawning call, where a client-side catch destroys any birth
the host did not command (`coop/world/weather_event_births`).
@@ -86,25 +96,37 @@ 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 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
+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 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.
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`
(a script-body watch) and, at a joiner's ready edge, reads the game's own registry and sends one
snapshot entry per in-flight event so the joiner replays it from the start with the in-flight
-override (`coop/world/event_active_sync`). Events whose only trace is a one-shot particle emitter
+override (`coop/world/event_active_sync`). A creature with no event row is not replayed: the host sends the
+creature's count of running events whole, by element id, and a client applies it to its mirror's
+registration in the event count (`coop/world/creature_event_relay`) [?]; the creatures whose event
+is computed from the local camera, render state or player 0 (the killer wisp, the fossilhound, the antibreather, the egg) keep their own per viewer. Events whose only trace is a one-shot particle emitter
(the meteor shower) are mirrored as cues: the host sees the row's `runEvent` return and broadcasts
the emitter's identity and position, a joiner's snapshot reads the live emitters from the object
index, and a client spawns the same emitter (`coop/world/event_cue_sync`).
@@ -148,8 +170,12 @@ 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
-[devices.md](devices.md).
+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 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
+[base-computer.md](base-computer.md).
## Who owns what
@@ -158,6 +184,7 @@ task state (`coop/world/daily_task_sync`). The rewards land in the shared balanc
| the clock, the sky | the host | streamed; the client's clock held at zero rate |
| rain, snow, fog, wind, lightning | the host | scheduler observed on the host, cancelled on the client; the client's births killed |
| the red sky | the host | its toggle sent from the host as it returns, refused on the client |
+| the Bad Sun's existence | the host | its spawn and its copy's `remove()` sent as they return, a client's own spawn refused; each peer's copy runs by the shared clock and its harm is local |
| fireflies | each peer | peer-symmetric union |
| ambient spawners | the host | refused on clients at the script-body gate; the camera-anchored ones per peer |
| a scheduled or story event | the host | observed at its verb; replayed on the client per row |
@@ -172,7 +199,7 @@ task state (`coop/world/daily_task_sync`). The rewards land in the shared balanc
| Kind | Direction | Carries |
|---|---|---|
| `ClockPose` (stream), `SkyState` | the host to all | the clock and the day number; the dome, the moon and the eye |
-| `WeatherState`, `LightningStrike`, `RedSky` | the host to all | the state fields; a strike location; the red sky's edge |
+| `WeatherState`, `LightningStrike`, `RedSky`, `BadSun` | the host to all | the state fields; a strike location; the red sky's edge; whether a Bad Sun exists |
| `FireflySpawn` | each peer, relayed | one spawn |
| `EventFire`, `EventSnapshot`, `EventCue` | the host to all; the host to one joiner; the host to all | a fired row; an in-flight event; an emitter cue |
| `WorldActorSpawn`, `WorldActorDestroy`, `WorldActorPose` (stream) | the host to all | an event actor's birth with scale and blob; its death; full-rotation poses |
@@ -183,8 +210,9 @@ task state (`coop/world/daily_task_sync`). The rewards land in the shared balanc
## Late join
The clock streams from the connect, so a joiner's first sample once its world exists sets its time
-and day number; the sky (its eye included), the weather state and the red sky's state, red or
-clear, are seeded at the joiner's world-ready.
+and day number; the sky (its eye included), the weather state, the red sky's state, red or
+clear, and whether a Bad Sun exists are seeded at the joiner's world-ready; the join blob already
+carries a saved Bad Sun copy, and the seed settles it against the host's state.
The in-flight events arrive as snapshot entries and replay with the override; the live cues are
re-sent; every tracked event actor is re-spawned on the joiner; the pyramid's gather in flight
is re-sent after its mirrors exist; the alarm's current flag is sent unconditionally, and the
@@ -201,6 +229,7 @@ edge. A one-shot cue a joiner was not present for is missed, by definition.
| A decorated Christmas tree spawns its gifts on each peer whose player sleeps through midnight, and a client's gifts are its own | `[RD]` the tree's own check of the local player's sleep at hour 0 |
| Several rolls are still per peer: the rare gamemode rolls (the one-percent forced quit), the server break-minigame variant, the underground loot mounds, the signal scramble and the radio-tower shuffle | `[V]` no lane under `coop/world` carries them; `coop/interactables/garbage_sync` names the mounds |
| The deer, hexahive, walking-tree, dirt-hole, beehive, flora and mannequin spawners are not refused on a client, so each peer rolls its own; the host's walking mannequin still crosses to clients as a prop, and the client's copy grows a brain of its own | `[V]` no row of `coop/world/spawn_authority` names them; the table in `docs/npcs-and-kerfur.md`, "Which spawners a client refuses"; the walker's crossing was measured on the rig: one key on both peers, `pawn=1` on both |
+| Rain falls inside the base for a player standing indoors, the host included: the game's own effect, the same in single player. The rain emitter follows the player's camera and spawns its drops around it, below the roof's rain-collision mesh, which stops only drops falling onto it from above | `[V]` `coop/world/weather_rain` never moves the emitter or the camera; `[RD]` the decompiled day-night cycle's emitter move |
| Trigger-volume fires (a bed event, a scare a player walks into) run per peer, as the single-player design intends | `[V]` by design, not a gap |
| Only the pyramid and the alarm have had the one-event-at-a-time pass; every other event rides the generic lanes on the per-row replay policy | `[V]` `coop/world/event_fire_sync`, the replay allowlist |
@@ -209,9 +238,9 @@ edge. A one-shot cue a joiner was not present for is missed, by definition.
| Concept | Files |
|---|---|
| the clock and the sky | `coop/world/time_sync`, `coop/world/day_edge`, `coop/world/sky_sync`, `ue_wrap/world/daynightcycle`, `ue_wrap/world/profile`, `ue_wrap/world/event_list`, `ue_wrap/world/skysphere` |
-| weather | `coop/world/weather_sync`, `coop/world/weather_rain`, `coop/world/weather_fog`, `coop/world/weather_lightning`, `coop/world/weather_redsky`, `coop/world/weather_event_births`, `ue_wrap/world/directionalwind` |
+| weather | `coop/world/weather_sync`, `coop/world/weather_rain`, `coop/world/weather_fog`, `coop/world/weather_lightning`, `coop/world/weather_redsky`, `coop/world/weather_badsun`, `coop/world/weather_event_births`, `ue_wrap/world/directionalwind`, `ue_wrap/world/bad_sun` |
| fireflies and the spawners | `coop/world/firefly_sync`, `coop/world/spawn_authority` |
-| events | `coop/world/event_fire_sync`, `coop/world/event_active_sync`, `coop/world/event_cue_sync`, `coop/dev/event_trigger` |
+| events | `coop/world/event_fire_sync`, `coop/world/event_active_sync`, `coop/world/creature_event_relay`, `coop/world/event_cue_sync`, `coop/dev/event_trigger` |
| event actors and the pyramid | `coop/world/world_actor_sync`, `coop/world/world_actor_mirror`, `coop/creatures/piramid_sync` |
| the alarm, emails, the task | `coop/world/alarm_sync`, `coop/world/email_sync`, `coop/world/daily_task_sync`, `ue_wrap/world/email` |
| tests | `harness/autotest/autotest_weather.cpp`, `autotest_fog_probe.cpp`, `autotest_eventforce.cpp`, `autotest_alarmforce.cpp`, `autotest_piramidforce.cpp`; the event drill, `coop/dev/event_drill`; the world roll drill, `coop/dev/world_roll_drill` |
diff --git a/docs/install.md b/docs/install.md
index 09e6fc1b4..60f304725 100644
--- a/docs/install.md
+++ b/docs/install.md
@@ -145,25 +145,36 @@ from the folder that contains `VotV-Win64-Shipping.exe`, then install normally.
- Manual: delete the `Mods\Multivoid` folder. The game boots completely stock
without it (UE4SS may remain; it does nothing on its own).
-For a full clean sweep, also delete the mod's runtime files next to the
-executable unless noted (all optional — they only store mod settings and logs):
-`multivoid.ini`, `multivoid.ini.example`, `multivoid.log`, `multivoid.prev.log`,
-`multivoid-loaded.txt`, `multivoid-compat-report.txt`, `multivoid-players.txt`,
-`multivoid-banlist.txt` (from builds before this one, no longer read), the folder
-`multivoid_servers` (it holds your hosted server's folder), the folder `multivoid_reports`
-(your saved bug reports), the skin-pak folder
+The `Mods\Multivoid` folder also holds everything the mod keeps for itself: your settings
+(`multivoid.ini` and `multivoid.ini.example`), your hosted server's folder `multivoid_servers`, the
+players' saved inventories in `coop_players`, `multivoid-players.txt` and `multivoid_thanks.txt`.
+Deleting it removes them with the mod (under a mod manager, the same folder inside the manager's
+profile).
+
+For a full clean sweep, also delete the mod's diagnostics next to the
+executable unless noted (all optional — they only store logs and reports):
+`multivoid.log`, `multivoid.prev.log`, `multivoid-loaded.txt`, `multivoid-compat-report.txt`, the
+folder `multivoid_reports` (your saved bug reports), the folder `multivoid_locale` if you made
+one (your translations), the skin-pak folder
`VotV\Content\Paks\LogicMods\multivoid` if you created one, and, from builds before this
-one, a folder `%LOCALAPPDATA%\Multivoid` (no longer read). Your identity is a separate
-file beside the executable, `multivoid_identity.key`, which only your Windows account can
+one, the files `multivoid.ini`, `multivoid.ini.example`, `multivoid-players.txt`,
+`multivoid_thanks.txt`, `multivoid-banlist.txt` and the folders `multivoid_servers` and
+`coop_players` (this build does not read them; the log names each once), and a folder
+`%LOCALAPPDATA%\Multivoid` (this build does not read it). Your identity is a separate
+file in the mod's own folder, `Mods\Multivoid\multivoid_identity.key` (under a mod manager, the
+same folder inside the manager's profile), which only your Windows account can
read (another account on the same PC that cannot read it keeps its own key beside it
-(if it may write the game folder; otherwise each launch runs on a temporary identity and the
-log says so), as `multivoid_identity_.key`; to move that one to another PC, rename
-it to `multivoid_identity.key`). If another program is holding that
+(if it may write that folder; otherwise each launch runs on a temporary identity and the
+log says so), as `multivoid_identity_ .key`). The file also records
+the PC it was made on: a copy placed on another PC, or in a game folder copied there, is set
+aside there (renamed `multivoid_identity.key.foreign-`) and that PC makes a new identity,
+so an identity cannot be shared by copying files. If another program is holding that
file when the game starts, that launch runs on a temporary identity and the log says so;
nothing is written over the file and your inventory is back the next launch. Leave it unless
you want a new identity: deleting it (and a `multivoid_identity_.key` of yours beside
-it, if any) orphans the inventory hosts stored for you, and copying
-it to another PC takes your identity with you.
+it, if any) orphans the inventory hosts stored for you, and so does deleting the
+`Mods\Multivoid` folder, which holds it. A `multivoid_identity.key` left beside the executable
+by an earlier build is no longer read (the log names it once); you can delete it.
## Troubleshooting
diff --git a/docs/join.md b/docs/join.md
index f2c48af4d..a594aaef3 100644
--- a/docs/join.md
+++ b/docs/join.md
@@ -23,20 +23,23 @@ sends a nonce, the host answers with its own nonce and a signature over both, th
back, and the password proof rides inside that reply (`coop/net/peer_admission`,
`coop/net/lobby_password`). The host then assigns a slot, and that assignment is the client's
signal to begin: there is no fourth "I am done" message. Each peer's Join carries its element id,
-nickname, skin, preferences, game target and build claim (`coop/session/player_handshake`); the host asserts
+nickname, skin, preferences, game target, build claim and game build (`coop/session/player_handshake`); the host asserts
who occupies each slot with roster rows, re-sent as state rather than announced as events, and a
row whose player number is zero means the slot is empty. That is how a departure reaches a
client: it conforms to the current row, it never observes an absence.
The version gate runs twice here. The build number is part of every packet header, so a peer on
another build never parses a message at all. The game target rides the Join payload and is
-compared byte for byte; on a mismatch the host kicks with a reason and the client shows why.
+compared byte for byte; on a mismatch the host kicks with a reason and the client shows why. The
+game build each side runs (`coop/game_build`: the executable's link stamp and the pak's index
+hash) follows it and is compared the same way, so one Multivoid on two different games is refused.
The build's own hash and whether it is the official build ride the same payload: a host on the
official build refuses another build (MV-H30), and a host on a modified build refuses the official
one (MV-H31), unless the host allows other builds.
-Before any of that, at the accept edge, the host applies one policy that costs no handshake and
-no seat: a per-address connection cap in the shape of MTA's join-flood
+Before any of that, at the accept edge, the host applies two policies that cost no handshake and
+no seat. First, an address the host ended for sending more than a player can is refused for ten minutes (`MV-H32`; the
+identity it proved is refused the same way at its proof). Then a per-address connection cap in the shape of MTA's join-flood
protection, four connections per thirty seconds by default and then thirty seconds of refusal
(`MV-H29`; the numbers are the `net.connect_cap` and `net.connect_window_s` settings, and 0
turns the cap off). On the internet lane no address is known when a connection arrives, because
@@ -126,8 +129,12 @@ consecutive scans. Two deadlines bound a pathological load, one on no progress a
past either the client announces anyway, logged loudly as degraded.
The client announces ready once per world, when a gameplay world is up, the prop registry is
-seeded for that world, no purge is running, and the probe has settled (`coop/session/net_pump`).
-This is the join barrier: no authoritative state lands in a world that is still churning. On
+seeded for that world, no purge is running, the probe has settled, and the prop key index holds the
+world (`coop/session/net_pump`). This is the join barrier: no authoritative state lands in a world
+that is still churning. The key index term has no deadline and no degraded announce: an index that
+does not hold the world would duplicate it, so a build whose index cannot exist ends the join
+(`MV-J32`) instead, and on any other build the term clears once the index has seeded and the
+object index's last drain left no backlog, the session's first ticks. On
every appearance in a co-op world the client is placed by the mod, never left at the host's saved
position that the transferred save carries: at the last spot its profile says it was standing
on, once, for a returning player, and at the base gate otherwise.
@@ -146,6 +153,9 @@ On the ready announce the host runs the replay for that slot, in this order
sent behind its own spawn, in the same drain, so the joiner takes the host's copy of state its
transferred save may predate; the drain's own pacing is what keeps that off the wire in a burst.
3. **Position corrections** for the save-authoritative objects the host moved during the window.
+ Each carries the host's drive context for the prop (claim generation and open), and the client
+ drops one older than a drive END still on its copy; a prop a hand held after its correction went
+ out gets a follow-up after the release `[?]`.
4. **Every state lane's current state**, each in its own kind: held items, weather, doors, lights
and containers, keypads, the sky, power, the ATV, the drone, the turbine, device
occupancy, the console and desk, physical modules, drives and racks, the laptop and its
@@ -234,7 +244,8 @@ session kept, and re-hosting is not the remedy.
Each phase of a join waits on a token that has to keep advancing, and fails naming that phase when
it stops: a byte of the download, a prop of the bracket, or the host's own once-a-second
`JoinPhaseNote` saying which phase it is working and how far in. The host beacons while it captures
-its world, while it streams it, while it drains the bracket, and -- the case nothing else covers --
+its world, while it streams it, while it enqueues the bracket, while the link drains it (the
+Draining phase, whose numerator is the KiB delivered), and -- the case nothing else covers --
while it is HOLDING the bracket because its own registry does not yet express its world. The
joiner's screen names the side that is working, and a failure names the phase, whether the host went
quiet or answered every second and got nowhere.
@@ -242,8 +253,12 @@ quiet or answered every second and got nowhere.
A beacon on its own is liveness, not progress. A host repeating one phase with the same numerator is
alive and stuck, and the wait ends on its budget all the same: measured, a host holding its bracket
kept a joiner waiting through 125 beacons and two minutes before this rule, and ends it at thirty
-seconds after. The engine's own world load is deliberately unwatched from here -- it is local and
-opaque, and the boot loop owns its cap.
+seconds after. Once the host has enqueued the whole bracket, nothing on its side can stall, so the
+Draining beacon carries the delivered KiB and renews the joiner's wait only while that number rises:
+a slow link joins slowly, and a link that stops delivering ends the wait at thirty seconds like any
+other. The beacon stops when a link sample taken after the close finds at most one packet owed. The
+engine's own world load is deliberately unwatched from here -- it is local and opaque, and the boot
+loop owns its cap.
### When a join ends early
@@ -251,10 +266,22 @@ A join that cannot be established, and a session that ends after it, both close
COULD NOT CONNECT or DISCONNECTED, one sentence, the site's own words, and a stable code to paste
into a report; a cancel is silent (`coop/net/end_reason`, `ui/end_reason_dialog`). The letter says
who decided: `J` the joiner; `H` the host, whose refusal travels as the transport's application
-end reason, so both ends log one code; `T` the transport. `MV-J28` and `MV-J29` also refuse a
-host's own start: a host from the hosting window before its world loads, with the code and the
+end reason, so both ends log one code; `T` the transport. `MV-J28`, `MV-J29` and `MV-J32` also refuse
+a host's own start: a host from the hosting window before its world loads, with the code and the
sentence on its status line.
+A join that ends before it completed, whatever ended it (a cancel, a connection that could not be
+made or was refused, the host closing the link before its world loads, a phase that timed out, a
+world that would not load or arrived damaged), puts the player at the main menu with the server
+browser, where a menu join starts (the one exception is a test scenario that joins from a world it
+booted into, which keeps that world when the join is refused): one already standing in the host's
+world, which the join loaded, is taken to the menu first by the travel the game's own quit runs
+(`harness/join_leave`, `harness/session_runtime`). MTA likewise forces its main menu back after
+every disconnect; the browser is this mod's. A session that ends another way (a death the revive
+gives up on, a quit, or the host closing the link once its world has started to load) travels to the
+menu from the session itself, without the browser (`coop/session/net_pump`). When the game will not
+travel, the player is told so (`MV-J31`), unless the reason the session ended is already on screen.
+
| Code | Meaning |
|---|---|
| `MV-J01` | Could not reach the server list |
@@ -279,7 +306,7 @@ sentence on its status line.
| `MV-J20` | The relay answered this machine's own name within one probe interval, and the host never answered at all |
| `MV-J21` | The host never finished preparing its world; it went quiet, or kept saying it was capturing and got nowhere |
| `MV-J22` | No byte of the world blob arrived for twenty seconds |
-| `MV-J23` | The world bracket never came: the host held it, or stopped answering |
+| `MV-J23` | The world bracket stopped coming: the host held it or stopped sending it, or the connection stopped delivering it |
| `MV-J24` | The engine never reached gameplay with the world the host sent |
| `MV-J25` | The world arrived damaged: its CRC failed, or the slot could not be written |
| `MV-J26` | The host never sent this player's inventory |
@@ -287,6 +314,8 @@ sentence on its status line.
| `MV-J28` | The player's `net.ice` is neither `all` nor `relay`, or `multivoid.ini` could not be read whole: locked or failing, or holding a zero byte or a UTF-16 mark (a file saved as UTF-16), the detail saying which of the two; nothing the policy governs starts until it is fixed |
| `MV-J29` | `net.ice=relay`, and the session has no TURN server to relay through |
| `MV-J30` | `net.ice=relay`, and the join is a direct one, which would show the host the player's address |
+| `MV-J31` | The game would not travel to the main menu: from a world a join starts in, or after a session ended (its game mode refused the travel, or it never dispatched or arrived); the player is still in that world |
+| `MV-J32` | The game's script loop could not be hooked at boot, or the prop key watches were refused, so a prop's key is never seen written and props cannot be matched between players by key; no session starts, a join or a host's own, the host's before its world loads, and a join from a world before the world is left |
| `MV-H01` | Wrong password |
| `MV-H02` | This server needs a password |
| `MV-H03` | Too many password attempts; try again in ten minutes |
@@ -318,6 +347,9 @@ sentence on its status line.
| `MV-H29` | Too many connections in a short time from one address, or from one proved identity over a relayed internet route; the cap is a host setting |
| `MV-H30` | The host runs the official build and this client another one (its build hash differs); the popup says to install the official release |
| `MV-H31` | The host runs a modified build and this client the official one |
+| `MV-H32` | This client sent more messages than the host's bound allows (a host setting), or was refused for ten minutes after such a close |
+| `MV-H33` | This client asked the host for its world a second time in one visit, which the game never does |
+| `MV-H34` | This client announced its world ready far more often than a join does (a flood past the host's replay burst) |
| `MV-T01` | No answer from the host, at the dial or later |
| `MV-T02` | No route to the host through its firewall or router |
| `MV-T03` | The signaling server could not reach the host |
@@ -380,13 +412,13 @@ and it is the reason the relay's own silence stays.
|---|---|---|
| `AuthHello`, `AuthChallenge`, `AuthProof` | both | the admission exchange, before any slot exists |
| `AssignPeerSlot` | host to client | the slot and the host's element id; means admitted |
-| `Join` | each peer | element id, nickname, skin, preferences, game target, build claim |
+| `Join` | each peer | element id, nickname, skin, preferences, game target, build claim, game build |
| `RosterRow` | host to all | who occupies a slot, zero meaning empty, and the host's number for that occupancy; re-sent as state |
| `SaveTransferRequest`, `SaveTransferBegin`, `SaveTransferChunk` | client, then host | the request; total bytes, sidecar bytes, checksum, game mode; the chunks |
| `PlayerInventoryBlob` | both | the per-player profile, pre-world |
| `ClientWorldReady` | client to host | once per world, at quiescence |
-| `JoinPhaseNote` | host to one client | once a second while the host is working a phase of this join: which phase, and how far in |
-| `SnapshotBegin`, `PropSpawn`, `PropSnapPos`, `PropDestroy`, `SnapshotComplete` | host to one client | the bracket |
+| `JoinPhaseNote` | host to one client | once a second while the host is working a phase of this join, or its link is delivering the bracket (`Draining`, `done` = KiB delivered): which phase, and how far in |
+| `SnapshotBegin`, `PropSpawn`, `PropSnapPos`, `PropDestroy`, `SnapshotComplete` | host to one client | the bracket; a `PropSnapPos` carries the host's drive context for its prop, and the client drops one older than a drive END still on its copy `[?]` |
| `EventSnapshot` | host to one client | one per in-flight event |
| `ChatLine` | host to one client | the retained chat record, one line each |
diff --git a/docs/master-server.md b/docs/master-server.md
index df63dd359..295768103 100644
--- a/docs/master-server.md
+++ b/docs/master-server.md
@@ -23,7 +23,7 @@ signaling leg is still plaintext TCP.
### The master
A small HTTP JSON service. A host announces itself with the world, the player count and its
-identity and gets back a session id, an opaque lobby id and a host token; it heartbeats every
+identity and gets back a session id, an opaque lobby id and a host token (it first asks for a nonce and signs it with the key its identity names, so a listed row names a host that holds the key it advertises; a build too old to sign is listed as unproved); it heartbeats every
thirty seconds with its player count, how they reach it (counted by the link it measures on each:
relayed, direct, on a LAN) and whether it is listed, can flip its listing, and leaves on stop. A
lobby that misses three heartbeats is reaped, so a killed host disappears from the browser within a
@@ -75,8 +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): a source that opens sockets and sends nothing holds a few slots for fifteen
-seconds, never the whole pool.
+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
@@ -112,9 +114,13 @@ only after a drill proves the deployed relay speaks it.
### Self-hosting
-Run the two binaries with their environment (the TURN secret, the signaling token, the
-signaling and TURN addresses) and a TURN server that shares the secret, then add the master to
-`net.masters` as another entry (`Home=host:port`) and pick its tab in the browser. `net.signaling`
+Run the two binaries with their environment (the TURN secret, the signaling token, the master's
+own name `COOP_DOMAIN` as a bare host, the signaling and TURN addresses) and a TURN server that
+shares the secret, then add the master to
+`net.masters` as another entry (`Home=host:port`) whose host is exactly that name, and pick its
+tab in the browser: a host signs its announce for the host of the URL it dialled, and the master
+refuses a proof made for any other name (`COOP_MASTER_NAME_ALIASES` lists extra names it
+answers to). `net.signaling`
names a relay only for a P2P connection made with no master at all; a lobby's own relay always
comes from its master. The rig builds and launches the signaling binary
locally for a scripted run, and serves a synthetic lobby list to the browser in place of a master.
@@ -134,9 +140,10 @@ 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/latest`, `/healthz` | the client; an operator | the released pair; liveness |
+| `/v1/host/challenge` | the host | a POST with body `{}`, answered with a 64-hex `nonce` that the master can re-derive and so stores nothing for (its expiry, random bytes and an HMAC over the caller's address): single use, alive about 30 s, bound to the caller's address, counted in its own rate class |
+| `/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), the game build it runs (`game_build`, refused unless of its shape), `listed`, and the lobby proof (`nonce` from the challenge, `proof`: the host's signature over the tag `multivoid-lobby-announce-v1`, its identity, the nonce and the master's name); a build that signs is refused without a valid proof (`409 proof-required`), a proof that is present must verify (`400 bad-proof`), a nonce that is spent, expired, another master's or another address's is `410 nonce-expired`, a build below the master's configured floor is `403 build-too-old`, an identity the operator listed in `COOP_BLOCKED_IDENTITIES` is `403 blocked`; a valid proof marks the row proved, and a proved announce for a key already listed replaces that row; 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 (its own rate class, 120 a minute per address), each row with the host's `identity` (its key) and `proved` (whether the host signed for it), 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 about the build claim); the dialing information for a lobby |
+| `/v1/latest`, `/healthz` | the client; an operator | the newest release per game version (`games`, from `COOP_LATEST_GAMES`), with the 0.9.0n entry as the `proto`/`mod` older clients read; 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 |
@@ -158,6 +165,6 @@ most until its heartbeats lapse.
| Concept | Files |
|---|---|
-| the services | `server/src/bin/master.rs`, `server/src/bin/signaling.rs`, `server/src/lobby.rs`, `server/src/ice.rs`, `server/src/version_gate.rs`, `server/src/tls.rs`, `server/src/common.rs`, `server/README.md` |
+| the services | `server/src/bin/master.rs`, `server/src/bin/signaling.rs`, `server/src/lobby.rs`, `server/src/lobby/host.rs`, `server/src/lobby_proof.rs`, `server/src/ice.rs`, `server/src/version_gate.rs`, `server/src/tls.rs`, `server/src/common.rs`, `server/README.md` |
| the mod's master client | `coop/net/master_slots`, `coop/net/lobby_client`, `coop/net/lobby_announcer`, `coop/net/http_client`, `coop/session/session_manager` |
| the rendezvous | `coop/net/signaling_client.h`, `coop/net/ice_config.h`, `coop/net/ice_policy.h`, `coop/session/host_mode` |
diff --git a/docs/npcs-and-kerfur.md b/docs/npcs-and-kerfur.md
index 04090609d..4ecceefc0 100644
--- a/docs/npcs-and-kerfur.md
+++ b/docs/npcs-and-kerfur.md
@@ -26,7 +26,9 @@ local twin by class after a deferred poll instead of spawning a second one
On a client the mirror is the game's actor with its brain parked: the AI timers neutralised and
the movement tick off, with the actor tick kept where the class's tick is per-viewer cosmetic,
-such as the wisp's fade and bob (`ue_wrap/actors/kerfur`, `ue_wrap/actors/wisp`). The host
+such as the wisp's fade and bob (`ue_wrap/actors/kerfur`, `ue_wrap/actors/wisp`). The zombie family and the
+fossilhound go further: their Blueprint's event graph is refused entry by entry on a mirror, but for the footstep and
+the fossilhound's per-viewer loop (`coop/creatures/npc_mirror_brain`). The host
streams a pose batch every tick, rotated fair-share when more characters are tracked than a batch
holds, and the mirror interpolates it in the same window a remote player uses
(`coop/creatures/npc_pose_drive`); a landing edge that depends on movement-component state is
@@ -60,7 +62,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); 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`) |
| underground garbage | left running | per-peer loot mounds; refusing one would delete a client's loot with no host replacement |
diff --git a/docs/piles.md b/docs/piles.md
index 9658ae1a5..68b9ddafc 100644
--- a/docs/piles.md
+++ b/docs/piles.md
@@ -216,7 +216,7 @@ replacement for the position key.
| `PropConvert` | the host to all | the atomic pile-clump convert: id, form, pose, scale, chip type, context |
| `TrashCarryPose` (stream) | the host to all | per-id poses for client-grabbed clumps and the clumps a broom sets rolling |
| `PropPose` (stream) | the host to all | the host's own held clump and its flight |
-| `PropSnapPos` | the host to one joiner | a position correction for a pile moved in the window |
+| `PropSnapPos` | the host to one joiner | a position correction for a pile moved in the window; it carries the host's drive context for the prop (claim generation and open), and the client drops one older than a drive END still on its copy `[?]` |
| `TrashPileState` | each peer, relayed | a dispenser's counter pair |
| `PropDestroy` | either role | a depleted dispenser |
diff --git a/docs/players.md b/docs/players.md
index c31350a89..614649634 100644
--- a/docs/players.md
+++ b/docs/players.md
@@ -127,10 +127,23 @@ 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
-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.
+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 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. A
+peer that joins sees it from the next pose, since the bit is level, not an edge.
+
+The hit verbs (`addDamage`, `punch`, `ragdollMode`) are refused on a puppet the same way, whoever calls them.
+Creature hits are the one relay: the host runs the creatures. A creature of a parked family (the zombies, the
+fossil hound) hits through those verbs, and its copy on a client makes no hit of its own, so when the host's
+creature hits a peer's puppet the host sends that peer a reliable `PlayerHit`, which runs the same verb on the
+peer's own player (`coop/player/player_hit`), armour and inventory mitigation included, so its health stream
+and hurt flash follow. `[RD]`: read from the code, not yet run on the rig.
Death is per peer and runs natively to its end: the sound, the dead flag, the black screen. The
cut is the last stage, the travel that would unload the world.
@@ -142,23 +155,32 @@ 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
used to dispose of -- the damage quadrants, the red overlay and the blood-loss effect
-(`coop/player/death_revive`). A host that dies is revived the same way, so a host death no longer
+(`coop/player/death_revive`). The revive also clears and verifies the pawn's burn timer so the
+normal player tick extinguishes its flame. A host that dies is revived the same way, so a host death no longer
ends the session; a revive that fails returns to the menu, and on a host that ends the lobby.
Other peers see the death through the ragdoll bit.
### Sleep
-Sleeping in the game is a per-process time dilation, so a lone sleeper would run twenty times
-faster than a shared clock. The gate is Minecraft's (`coop/player/sleep_sync`): a peer entering
-a bed keeps the cosmetic half while the dilation is undone every tick; each peer's in-bed edge is
-reported, the host tallies and announces how many are sleeping, and when everyone is in bed the
-host broadcasts the acceleration. Any wake ends the night for everyone; only a natural end, the
-host sleeping it out, grants everyone a full night. Nightmares roll on the host only.
+The host sleeps with the game's own sleep: its world runs at the game's speed-up and a client sees the
+clock and the host's actors move faster (`coop/player/sleep_sync`). A client that sleeps keeps its
+world at the normal rate: a watch on the game's sleep call takes the world's dilation back to 1 in
+the frame of the call and, while the rest lasts, runs the player's body and the world's effects at
+the game's own factor, with food and coffee settled to the rate they would have had
+(`coop/player/sleep_rest`). What crosses the wire is who rests. Each peer's state (awake, resting
+in a bed or fainted, dreaming in a nightmare) with the bed's class name goes from a client to the
+host, which relays it to every other client; the host's own sleeping flag goes to the clients, which show
+one chat line naming the host when it rises. A joiner is sent the host's flag and every peer's
+state that is not awake, and a leaver is told to every client as awake. A client holds its
+nightmare chance at zero, so a nightmare is a roll of the host's machine alone. `[?]`
### Inventory
@@ -184,12 +206,12 @@ item twice after a host restart, and an older one would lose it. A host that qui
saving takes its players' profiles back to its last save together with its world. The copy that
is written is the one taken when the game gathered the world into its save object, which during
a game event is not the moment of the save (see [join.md](join.md), the save transfer). The
-profiles stand beside the file the world is in, under `coop_players//` in the game
-folder, and one world is written under more than one name: a quicksave writes a new subsave file,
+profiles are keyed by the file the world is in: they stand under `coop_players//` in the
+mod's own folder (`Mods\Multivoid`), and one world is written under more than one name: a quicksave writes a new subsave file,
and a plain save made after loading a subsave writes the main slot, so a save to another slot
first copies the whole set beside that file. A profile reaches the host up to a second after the
pickup it reports, so a save inside that second holds a profile one pickup behind its world. That identity is
-a key file beside the game executable, under a private access list that admits only the
+a key file in the mod's own folder, bound to the machine it was made on, under a private access list that admits only the
account that made it (`coop/net/peer_identity`; [install.md](install.md) says where and
what a second account on the same PC gets). The world-side effect of a pickup or a drop is
on the props lane.
@@ -217,7 +239,7 @@ setting), the shape of MTA's join-flood protection (`coop/net/connect_history`).
The host's moderation is a set of commands -- `/kick`, `/ban`, `/banid`, `/unban`, `/tphere` --
which the player list and the Administration panel now run for the host. A friend can be given
`/kick`, `/ban`, `/banid` or `/unban` by a line in the server's permission files
-(`multivoid_servers\\permissions\users\.json`, LuckPerms' format, e.g.
+(`Mods\Multivoid\multivoid_servers\\permissions\users\.json`, LuckPerms' format, e.g.
`{"permissions": ["multivoid.kick"]}`; a player's id shows when you hover their name in the player
list or the Administration panel); `/tphere` stays the host's until a command can name where to
teleport. The host can deny itself a command the same way, in a file named by its own id (hover
@@ -254,7 +276,7 @@ host. A setting that holds a password is never set or shown by command.
| damage from contact | the victim's machine | other bodies' impact entries are cancelled |
| damage from enemies | the host detects, the victim applies | a reliable relay to the hit peer |
| death and revive | that peer | the native chain; the run-ending travel refused by its author |
-| sleep | each peer reports; the host tallies, accelerates and ends | |
+| sleep | each peer sleeps natively; each peer reports its rest state, the host relays it and its own sleeping flag | |
| inventory | that peer; the host stores it | never on the wire as gameplay |
| roster, kick, ban | the host | rows asserted as state; a ban by id and address at the identity proof |
@@ -267,8 +289,8 @@ host. A setting that holds a password is never set or shown by command.
| `Join`, `RosterRow` | each peer; the host | identity, nickname, skin, preferences; who occupies each slot |
| `SkinChange`, `NameplateChange`, `NickColorChange` | each peer, relayed | the three preferences |
| `HandItem`, `ItemActivate` | each peer, relayed | the held item's class, its actor's name and its hold slot's item name (the key of the game's item tables); the flashlight state |
-| `PlayerDamage` | host to the hit peer | an enemy hit to apply |
-| `SleepState` | each peer to the host; the host to all | in bed or not; the tally; accelerate; end |
+| `PlayerHit` | host to the hit peer | a parked creature's hit on the peer's puppet: the verb (addDamage or punch) to run on its own player |
+| `SleepState` | a client to the host; the host to all | a rest state with its bed (awake, resting, dreaming); the host's sleeping flag; one peer's rest state relayed |
| `PlayerInventoryBlob` | both | the per-player profile (items, vitals, standing spot), pre-world |
| `TeleportClient` | host to one peer | a placement; the administration action, not the join |
@@ -277,8 +299,9 @@ host. A setting that holds a password is never set or shown by command.
The puppet spawns on the first pose that arrives. The roster rows, skin, nameplate and colour
preferences are in the set of messages a host may send before the joiner's world exists, so a
choice made during the load is not lost. Hand items and item states are replayed to the joiner
-at its ready edge. A joiner arrives awake: a running acceleration is ended and the tally re-run.
-The inventory is seeded before the world. A peer's death state is not seeded; a joiner sees a
+at its ready edge. A joiner is sent the host's sleeping flag and the rest state of every peer
+that is not awake. A joiner that rests before the host holds its world ready has that report refused, and repeats
+it each time the host's sleeping flag reaches it, which the host sends once it holds the joiner ready. The inventory is seeded before the world. A peer's death state is not seeded; a joiner sees a
dead peer through the ragdoll bit of its next pose.
## Known limits
@@ -304,7 +327,7 @@ dead peer through the ragdoll bit of its next pose.
| skins | `coop/player/client_model`, `coop/player/skin_registry`, `coop/player/skin_effects`, `coop/player/local_body`, `coop/player/skin_preview`, `ui/skins_panel`, `assets/paks/` |
| held and toggled items | `coop/player/hand_item`, `coop/player/item_activate`, `coop/player/flashlight_click_sound` |
| damage and death | `coop/player/player_damage`, `coop/player/death_revive`, `coop/player/run_end_travel`, `coop/player/ragdoll_gate` |
-| sleep | `coop/player/sleep_sync` |
+| sleep | `coop/player/sleep_sync`, `coop/player/sleep_rest` |
| inventory | `coop/items/player_inventory_sync` |
| moderation | `coop/moderation/moderation`, `coop/moderation/ban_list`, `coop/moderation/seen_players` |
| tests | `harness/autotest/autotest_death.cpp` (the drill) + `death_state_probe.cpp` (the reads it and `autotest_runend.cpp` share), `autotest_damage.cpp`, `autotest_playerdmg.cpp`, `autotest_ragdoll.cpp`, `autotest_puppetframe.cpp` |
diff --git a/docs/props.md b/docs/props.md
index b9837d790..a8df775c6 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
@@ -48,6 +50,12 @@ 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, destroys its copy, and pays
+only once that incarnation has stopped being 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
@@ -156,14 +164,19 @@ 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
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
@@ -210,6 +223,45 @@ host re-publishes. The far side applies it through the prop's `loadData`, whose
the reel case; the drive box's look follows its name, which a record does not carry, so the
receiver re-derives it from `opened` (`coop/props/prop_record_refresh`).
+### Notebook
+
+The notebook editor claims the prop while open: the host arbitrates the claim so only the
+current holder can edit it. The editor streams its page array in chunked `NotebookText`
+snapshots; the host accepts and broadcasts text only from that holder. Closing the editor
+publishes the notebook's updated prop save record (`coop/interactables/notebook_sync`,
+`coop/props/prop_save_data`). A joiner receives the notebook state from the loaded save; later
+in-session edits have no notebook-specific join replay. `[?]`
+
+### Sleeping bags and mattresses
+
+`prop_sleepingbag_C` exposes `actionOptionIndex` and has an `unwrapOnBed` field. Its generated
+Blueprint graph carries a `FinishSpawningActor` result typed as `sleepingbagWrap_C`. The wrapped
+form derives from `bed_C`, which derives from `Aprop_C`; it exposes `fold()`, whose graph carries a
+`FinishSpawningActor` result typed as `prop_sleepingbag_C`. The blue, brown, Stolas and mattress
+forms are subclasses of these two base classes. `[RD]`
+
+The deployed Blueprint form finishes with `Key=None`; after `FinishSpawningActor` the host assigns
+it a synthetic prop key and retries the ordinary lifecycle express. The client gates the packed
+bag's `actionOptionIndex` and the deployed bag's `fold()` call, sends the
+source prop's host element id and the action byte when unfolding, and cancels its local Blueprint
+verb. The host checks that the sender is in reach and that the named prop is still in the expected
+form, then invokes that same Blueprint verb on its copy. The Blueprint owns the replacement birth
+and source destruction; the host spawn watcher and prop lifecycle carry those edges to every peer.
+The joiner's host save and ordinary prop snapshot seed whichever form is live. The client does not
+choose the replacement class or transform. `[V]` `coop/interactables/sleepingbag_sync`,
+`ue_wrap/actors/sleepingbag`, `coop/props/host_spawn_watcher`, `coop/props/prop_lifecycle`.
+The receiver also handles a host replacement that reuses a save key with a different actor class:
+it retires the old local rendering before materialising the successor and consumes the source's
+following destroy, so that edge cannot remove the new form. `[V]` `coop/props/remote_prop_spawn`,
+`coop/props/remote_prop_destroy`.
+
+Unfolding is relayed as the interaction action byte so the Blueprint retains its own placement and
+mattress branches. The host runs it with the sender's remote-player puppet, preserving the game's
+player-relative checks. The intent is refused if the source lacks a host prop id, is out of reach,
+or has already changed form. `[RD]` the packed Blueprint graph uses two sphere traces, checks the
+target bed, spawns the wrapped actor and destroys its source; the wrapped graph's `fold()` spawns a
+packed actor and destroys its source. `[V]` sleepingbag CFG and generated SDK parameters.
+
### Props that change on their own
A joiner loads the host's save, so every keyed world prop is a real save-loaded actor with its
@@ -236,6 +288,8 @@ is built; concrete and food are designed and not built.
| container contents | the host | slices of its object array; an extraction is an intent |
| a stuck prop, and its unstick | the peer that sticks or frees it; every peer runs the component's own verb | one reliable message |
| a self-changing prop's progression | the host | park, corrector or anchor, per prop |
+| notebook editor occupancy | the host arbitrates | a claim keyed by the owning prop |
+| notebook page text | the current notebook holder; the host relays it | chunked `NotebookText` snapshots; closing publishes the prop save record |
| a client's shared-world spawners | the host | refused on a client at the script-body gate; shared-world content arrives only from the host |
## Wire messages
@@ -249,14 +303,15 @@ is built; concrete and food are designed and not built.
| `PropDriveEnd` | the host to all | the final pose and velocity of a driven prop that rested or that a hand took; closes its generation |
| `PropDropIntent`, `ReelEjectIntent` | a client to the host | a place, or an unavoidable birth, for the host to author |
| `PropStickState` | the peer that sticks or frees it, to all | frozen or static, the commit pose and the hold the stick ended; or an unstick, with the pose and velocity |
-| `PropSnapPos` | the host to one joiner | a position correction for a save-authoritative prop moved in the join window, with a keyed prop's frozen and sleep |
+| `PropSnapPos` | the host to one joiner | a position correction for a save-authoritative prop moved in the join window, with a keyed prop's frozen and sleep; it carries the host's drive context for the prop (claim generation and open), the client drops one older than a drive END still on its copy, and a prop a hand held after its correction went out gets a follow-up after the release `[?]` |
+| `NotebookText` | the editor holder to all (relayed via host) | chunked snapshots of the notebook page array |
| `ContainerState`, `ContainerContents` | the presser; the host | open or closed; one slice of the host's object array |
| `InventoryPickup` | each peer, relayed | the pocket blip, so others hear a pickup |
## Late join
-The join page owns the mechanism: explicit deletes for the props the joiner's save had and the
-host's world no longer has, then the snapshot bracket with one spawn per live keyed prop (adopted by
+The join page owns the mechanism: explicit deletes for the props the joiner's save had that are
+gone from the host's world, then the snapshot bracket with one spawn per live keyed prop (adopted by
key, created when missing, transform converged), then position corrections for what the host moved
during the window, then the membership sweep that removes the locals the host never claimed. A keyed
prop's frozen and sleep converge with its snapshot row and its correction, and a flag change without
@@ -293,6 +348,7 @@ prop resolves.
| Concept | Files |
|---|---|
+| sleeping bag and mattress replacement | `coop/interactables/sleepingbag_sync`, `ue_wrap/actors/sleepingbag`, `coop/props/host_spawn_watcher`, `coop/props/prop_lifecycle` |
| the birth and death seams | `coop/props/prop_lifecycle`, `coop/props/host_spawn_watcher`, `coop/props/prop_element_tracker`, `coop/props/prop_synth_key`, `coop/props/registry_reaper`, `coop/props/prop_echo_suppress` |
| the receivers | `coop/props/remote_prop` (held), `coop/props/remote_prop_spawn` (birth: adopt, converge, create), `coop/props/prop_fresh_spawn` (the materialiser), `coop/props/prop_wire_parity`, `coop/props/active_drive`, `coop/props/prop_sound` |
| a client's intents | `coop/props/prop_drop_intent` |
@@ -300,6 +356,7 @@ prop resolves.
| a prop under a hook's drive or a broom's push | `coop/props/prop_drive_host` (the host's set and stream), `coop/props/prop_drive_stream` (the receiver), `coop/items/hook_prop_claim` (the hook lane feeding it), `coop/items/hook_constraint` (the tie itself, host-only), `coop/items/broom_push` (the broom's push feeding it) |
| containers | `coop/props/container_contents_sync`, `coop/items/save_record_wire`, `coop/interactables/interactable_sync` |
| a record that changes in place | `coop/props/prop_record_refresh` (the drive box, the reel case) |
+| notebook sync | `coop/interactables/notebook_sync`, `coop/props/prop_save_data` |
| the pocket blip | `coop/items/inventory_pickup_sync` |
| the join | `coop/props/prop_snapshot`, `coop/props/snapshot_census`, `coop/props/join_membership_sweep`, `coop/props/unresolved_pose_ledger` |
| the spawners a client must not run | `coop/world/spawn_authority` |
diff --git a/docs/release.md b/docs/release.md
index d8a554e74..64417c40e 100644
--- a/docs/release.md
+++ b/docs/release.md
@@ -14,8 +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`; `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`. 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 those
+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
@@ -64,21 +66,22 @@ 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
done when the page shows the asset and its SHA256; the failure email is the only backstop.
7. **The published row.** Append `published` with the same N, game, tag, commit and today's
date, and push it while watching the green run.
-8. **The update check.** For a stable, set `COOP_LATEST_PROTO=` and
- `COOP_LATEST_MOD= b` in EVERY master's environment and restart each (a player reads the
- check from the master they chose, [master-server.md](master-server.md)); the in-game line
- informs and never gates a join. A dev release skips this unless it is retiring an older
+8. **The update check.** For a stable, set `COOP_LATEST_GAMES` in EVERY master's environment to
+ the line the publish step printed -- every game version's newest stable, from the ledger, the
+ whole list each time -- and restart each (a player reads the check from the master they chose,
+ [master-server.md](master-server.md)); the in-game line informs and never gates a join. A dev release skips this unless it is retiring an older
cohort, since the client has no dev-or-stable axis and compares the build number alone. Then
`.github/ci/verify_latest.ps1` must pass: it asks every official master
- (`kOfficialMasterSlots`), and one left behind fails it (`-AllowDev` when the masters were
- pointed at a prerelease on purpose).
+ (`kOfficialMasterSlots`) for every game's newest release and for the 0.9.0n pair older clients
+ read, and one left behind fails it (`-AllowDev` when the masters were pointed at a prerelease on
+ purpose).
If `src/votv-coop/assets/thanks/thanks.txt` changed since the last release, publish it to
every master's `COOP_THANKS_FILE` too: the build embeds it, the master serves it, and the higher
`revision` wins in the game, so the two are kept equal. **Put it in place atomically** -- copy
diff --git a/docs/scope.md b/docs/scope.md
index be0a5ef70..c454e3e06 100644
--- a/docs/scope.md
+++ b/docs/scope.md
@@ -9,14 +9,15 @@ the commit that ships the change.
| System | What is synced | Who owns it |
|---|---|---|
-| Players | position and pose, the ragdoll and faint display state, health, food and sleep as fractions, the held item, skins, nameplate visibility, nickname colour; enemy hits delivered to the hit peer; death runs natively and the level travel is refused so the world survives | each peer computes its own vitals and death; the host delivers enemy hits |
+| Players | position and pose, the ragdoll and faint display state, health, food and sleep as fractions, the held item, skins, nameplate visibility, nickname colour; a zombie or fossil hound hit on a peer delivered to that peer `[RD]`; death runs natively and the level travel is refused so the world survives | each peer computes its own vitals and death; the host delivers those hits; every other creature's hit lands by the peer's own copy |
| Chat and the event feed | text chat; join, leave and error lines | the host relays chat; each machine surfaces its own transport errors |
| Voice | 3D positional voice | each peer |
| Master server and browser | an opt-in listing of the host's game, the update check, signaling for NAT traversal | the host opts in per session; the master is external infrastructure |
| Physics props | pickup, drag, throw and drop; per-grab authority; an identity that survives saves and rejoins; world-to-inventory and inventory-to-world transitions | the holder while held; the host owns everything at rest |
+| Sleeping bags and mattresses | folded/deployed actor replacement; clients send the source id and interaction action, the host runs the Blueprint transition and authors the replacement | host ([props.md](props.md)) |
| Piles and trash | the whole collection loop: piles, clumps, carrying, depositing | the host |
| NPCs and creatures | pose, spawn and despawn, the kerfur prop-to-NPC cycle; enemies target every peer and a pursuit can switch targets between peers; creatures whose AI reads the local player are owned per peer and visible to all | the host, except the per-peer owned creatures |
-| Doors, lights and devices | doors, light switches and light groups, keypads, power panels, the garage, appliances, locker lids, windows, grime, the wind turbine, containers and their contents, the delivery drone, shop orders, the coin gun and the shared balance | the host holds the state; a client sends an intent that the host performs |
+| Doors, lights and devices | doors, light switches and light groups, keypads, power panels, power cords and their socket links, the garage, appliances including the coffee maker, vending machine, coffee bag and cups, locker lids, windows, grime, the wind turbine, containers and their contents, the delivery drone, shop orders, the coin gun and the shared balance | the host holds shared state; a client-originated cord link is relayed by the host |
| The signal workstation | the dish, pinging, signal catch, downloads, decoding, the playback deck, drives and racks, tapes, the physical modules, the laptop and its floppies, the meadow signal database, the signal-server simulation and the notices it produces | one authority per axis: the pressing peer authors inputs, the host runs every simulation |
| World state | the clock, the sky, weather and lightning, fireflies, ambient spawners, the story and scheduled events, the base alarm, emails, the daily task, world props that change over time | the host; clients mirror and never roll their own randomness |
| Saves | the joiner boots from the host's world; the host is the only peer that saves | the host |
diff --git a/docs/send-path.md b/docs/send-path.md
index fa1f472d4..5b9a21800 100644
--- a/docs/send-path.md
+++ b/docs/send-path.md
@@ -44,15 +44,19 @@ Each slot is measured on two quantities, both byte counters:
acknowledged at. Little's law read backwards: the milliseconds of delivery standing on the wire.
Both terms are reliable-only, which is the only way the division means anything.
-A round trip is timed as well, on the high lane the pose stream rides, and is kept as a diagnostic
-rather than as a control input. A busy link is probed ten times a second and reports once; an idle
-one is probed once a second with the same 32-byte datagram, so a baseline is never taken only under
-load.
-
-Neither term is compared against a reference derived from its own past, and that is the whole
-design. Two laws built before this one were, and both were refuted on the same property: a
-delay-versus-baseline rule of the LEDBAT family, and a port of ENet's peer throttle, which judges
-against the previous interval's lowest smoothed round trip
+A round trip is timed as well, on the high lane the pose stream rides. A busy link is probed ten
+times a second and reports once; an idle one is probed once a second with the same 32-byte
+datagram, so a baseline is never taken only under load. A probe sent while at most one packet of
+ours was on the wire is admitted to the link's **base** round trip, the minimum over the last ten
+one-minute minima (the per-minute history of RFC 6817 section 2.5). The **queue** the law reads is
+the inflight time above that base: at a relay's 190-420 ms the raw inflight would read as queue
+where there is none.
+
+What the law compares inflight against is a reference a queue of ours cannot enter: a probe sent
+under load is not admitted, so the base never learns a standing queue. Two laws built before this
+one compared against a reference derived from their own loaded past, and both were refuted on that
+property: a delay-versus-baseline rule of the LEDBAT family, and a port of ENet's peer throttle,
+which judges against the previous interval's lowest smoothed round trip
(`reference/enet/protocol.c:908-911`). A standing queue moves that reference,
so "better than baseline" stops meaning anything exactly when it starts to matter. The ENet port
was measured doing it: the baseline rose from 18 ms to a median of 1,545 ms, its accelerate branch
@@ -68,13 +72,15 @@ One decision per sample, ten times a second, so a join can climb inside its own
| Branch | When | What it does |
|---|---|---|
| hold | nothing queued, or delivery not measured yet | leaves the rate alone |
-| brake | inflight above 400 ms, which includes a wire holding reliable bytes while nothing at all is being acknowledged | drops straight to the measured delivery |
-| climb | inflight at or under 150 ms | 5/4 of the rate plus 8 KB/s |
+| brake | queue above 400 ms (inflight above the base), or a wire holding reliable bytes while nothing at all is being acknowledged, whatever the base | drops straight to the measured delivery |
+| climb | queue at or under 150 ms | 5/4 of the rate plus 8 KB/s |
| dead band | between the two | leaves the rate alone, because a controller with no dead band hunts |
| anchor | after the above, whenever there is delivery to measure | a rate may not stand above 5/4 of the largest recent delivery reading |
| envelope | last, and unconditionally | clamps the result into the floor and ceiling below, so on a link under about 26 KB/s the floor stands above what the anchor just allowed |
-The thresholds are the regimes above: the climb stops where health stopped (p90 163 ms) and the
+The thresholds are the regimes above, measured as inflight on 5-17 ms links where all of it was
+queue, and read as queue (inflight above the base) so they mean the same on a slow path: the climb
+stops where health stopped (p90 163 ms) and the
brake fires an order of magnitude under the collapse (p10 1,458 ms), so a threshold between them
flags 93.4% of the collapse and none of the health. The brake goes to the measurement rather than
to a fraction of the current rate, because a multiplicative back-off from a rate hundreds of times
@@ -186,7 +192,7 @@ counters restart with the connection in the same way.
| The time bound prices bytes against the delivery measured at the moment they are committed, and cannot retract them; a rate that collapses inside a second leaves a tail | `[V]` `coop/net/send_admission` |
| The occupancy compared is an estimate. It reads high when a drain went unseen, which is the safe direction, and low by a bounded amount -- the stream header the transport adds after its own check, and the skew between two loads -- until the next anchor corrects both | `[V]` `coop/net/send_admission` |
| A link that genuinely is slow is still a long download; the mod can only decline to make it worse | `[V]` `coop/net/send_rate_control` |
-| The round trip is a diagnostic, not an input. It rides the high lane, which the scheduler serves ahead of the bulk queue a transfer stands in, so it under-reports what that transfer is waiting behind | `[V]` `coop/net/send_rate_control` |
+| The round trip enters the law only as the base that inflight is read above. It rides the high lane, which the scheduler serves ahead of the bulk queue a transfer stands in, so a probe under-reports what that transfer is waiting behind; that is why only probes sent with the wire nearly empty count toward the base | `[V]` `coop/net/send_rate_control` |
## Code map
diff --git a/docs/signals.md b/docs/signals.md
index 7afcd7f03..2b534ab8b 100644
--- a/docs/signals.md
+++ b/docs/signals.md
@@ -69,9 +69,11 @@ rolls both random terms and streams the output vector (decoded, the needle, the
frequency and polarity data and offsets, the cooldown) unreliable, about ten times a second, and
the client interpolates it and overwrites its own accrual, whose garbage the overwrite hides. The
triangulation ping is a latent state machine gated on a flag; it runs on one machine, the
-presser's, and receivers treat the flag as bookkeeping and never write it, because writing it
-woke a phantom parallel machine on every observer. A desk hold covers the pinger's run so nobody
-else can claim the desk mid-ping. The run is the pinger's presentation, and its verdict is the
+presser's. Receivers keep the flag clear (setting it woke a phantom parallel machine), while the
+presser streams the stage and three accumulator values at 10 Hz. The host validates the desk
+holder and ping owner, then relays these visual-only values; mirrors reapply them after their idle
+local process tick. A desk hold covers the pinger's run so nobody else can claim the desk mid-ping.
+The run is the pinger's presentation, and its verdict is the
host's (`coop/interactables/desk_ping_sync`): at stage 3 a client's gate refuses the verdict and
sends its view and triangle, the host checks the claim, the running ping and the triangle and
primes its own machine, whose next coordinate process rolls the verdict, and the outcome reaches
@@ -174,7 +176,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, 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`).
@@ -194,7 +200,7 @@ not built. The ATV's physical modules are on [vehicles.md](vehicles.md).
| a screen's occupancy | the host arbitrates | first claim wins; a client claims optimistically |
| desk input scalars | the last presser | polled deltas, relayed except to the originator |
| the download simulation, the needle, the rate | the host | a streamed output vector; the client overwrites its own |
-| the ping | the presser's machine; its verdict the host | the verdict as an intent; observers keep the flag as bookkeeping |
+| the ping | the presser's machine; its verdict the host | 10 Hz visual progress snapshots relayed by the host; observers keep the run flag clear |
| the cursor | whoever moves it | a sixty-hertz stream |
| the desk's sounds | the presser | effect events at the audio seam |
| the sky-signal set, the catch, the dishes | the host | roller, the host's event, parked client sim |
@@ -219,7 +225,7 @@ not built. The ATV's physical modules are on [vehicles.md](vehicles.md).
| `DeskSimPose` (stream), `DeskCursorPose` (stream) | the host to all; the mover to all | the simulation outputs; the cursor |
| `DeskSndFx` | the presser, relayed | an audio effect event |
| `SkySignalState`, `SkySignalCatch`, `DishAimState` | the host; the host; the occupant | the signal set; a catch; committed coordinates |
-| `DeskPingVerdict` | a client to the host; the host to that client | a ping's or a cheat insta-catch's verdict to roll, the view and triangle it rolls from; its refusal, or the pinger's find |
+| `DeskPingVerdict` | the pinger to the host and the host to observers; host to pinger for verdict | visual progress snapshots; a ping's or cheat insta-catch's verdict and aim; a refusal or the pinger's find |
| `DishArm`, `DishSnapshot`, `DishCalib`, `DishPose` (stream) | the host; the host to a joiner; the host, to all, to an intent's author and to a joiner; the host | the download's arm and reset, with the host's decoded and polarity; every dish's pose for a joiner; the changed dishes' precision, the live dishes an intent named (to all once the host performed any of it, to its author alone when it performed none), and every dish's for a joiner; dish poses |
| `DishCalibIntent` | a client to the host | the dishes whose precision the client's player just set with the toolgun's calibration tool or the uncalibrator |
| `SavedSignalAppend`, `SavedSignalDelete`, `MeadowAppend`, `MeadowDelete`, `MeadowOrder` | any peer, relayed; order from the host | list rows by content hash; the database's order |
diff --git a/docs/status.md b/docs/status.md
index 62ae20695..8acd6c015 100644
--- a/docs/status.md
+++ b/docs/status.md
@@ -5,9 +5,9 @@ row is. This is a count view, never a percentage: the number of facets a system
is not enumerable, so a single "N% done" would hide which systems are solid and which are untouched.
**State** says how far the row is: `works` (established in play), `tested` (exercised by a scripted
-run), `built` (the lane exists and has not been exercised), with the known breaks counted where
-there are any; the breaks themselves are on the system's page. Most mechanic-level facets sit at
-`built`; that majority is the honest state. **Owner**: `host` (the host writes, clients mirror), `peer` (each peer authors its own slice
+run), `built` (the lane exists and has not been exercised); known breaks are counted where there are any,
+and the breaks themselves are on the system's page. Most mechanic-level facets sit at `built`; that
+majority is the honest state. **Owner**: `host` (the host writes, clients mirror), `peer` (each peer authors its own slice
and streams it), `presser` (whoever performs the action authors it, the host relays), `arbiter` (the
host validates and commits contested writes), `local` (never shared).
@@ -21,7 +21,7 @@ host validates and commits contested writes), `local` (never shared).
| Nameplates, colours, skins | nickname, ping, health bar, visibility preference, colour, the body skin | peer, arbitrated by the host for uniqueness | at join | works |
| Chat and the event feed | text lines, join and leave lines, a retained history shown to late joiners | presser; host relays | seeded from the host's retained log | works |
| Voice | Opus frames, 3D positional | peer | state replay | works |
-| Player damage and hazards | enemy hits delivered to the hit peer; vitals as fractions | host delivers, peer computes | none needed | works |
+| Player damage and hazards | vitals as fractions; a zombie or fossil hound hit on a peer delivered to that peer `[RD]` | host delivers, peer computes | none needed | vitals work; the hit delivery is read from the code and not yet run |
| Death | the native death runs to its end; the level travel is refused and a revive written in its place | peer | none needed | tested |
| Teleport (dev) | host pushes a placement | host | the join placement | works |
@@ -30,9 +30,10 @@ host validates and commits contested writes), `local` (never shared).
| System | Synced | Owner | Late join | State |
|---|---|---|---|---|
| Physics props | spawn, pose, grab, carry, throw, drop, destroy, a hook's drag and a broom's push (built); identity across saves and rejoins; each prop's own save record, for the classes that keep one; client-born props, including what a player spawns from the sandbox menu | presser while held, host at rest and under a hook | snapshot; a player-authored birth is claimed at its seam and re-claimed by the host's echo, so a pending sweep cannot doom it | works |
+| Sleeping bags and mattresses | a client interaction action sent to the host; the host runs the Blueprint that replaces the packed bag or mattress with its deployed form, or folds it back | host | host save and prop snapshot contain the currently live form | built (see [props.md](props.md)) |
| Chip piles and clumps | the grab, carry, throw and re-pile cycle, and a broom's sweep | host with client intents | snapshot, a spawn-time bind, and a bounded hold for a pile the save load has not reached; a clump rolling at the join arrives as the pile it lands as | works |
| Trash-bits piles | the counter pair | presser and host | snapshot | built |
-| Containers | open and close, contents (a slice of the host's object stack) | presser; host for contents | snapshot | works, two known breaks |
+| Containers | open and close, mouse-driven torque samples for physical swinger hinges, contents (a slice of the host's object stack) | presser; host for contents | saved open/closed state; active hinge motion is not replayed | built, two known breaks; hinge motion needs a two-peer physics check |
| NPCs | spawn, despawn, pose, state for the generic creatures | host | snapshot | works |
| Kerfur | the prop-to-NPC conversion cycle, per-kerfur skins | host | snapshot plus adoption | works |
| Owner-entity creatures | a creature whose AI reads the local player is owned per peer and mirrored to the rest | peer | keepalive | built |
@@ -47,6 +48,7 @@ host validates and commits contested writes), `local` (never shared).
| Story and scheduled events | host-observed fires replayed on clients by a per-event policy; the active-events registry mirrored for late joiners | host | replay and snapshot | built |
| Alarm | the base klaxon | presser | snapshot | built |
| Balance | the shared points total | host | replay at connect | built |
+| Coffee maker, vending machine, coffee bag and cups | client actions execute on the host; machine fields and remaining cup `uses` mirror; cup births use prop sync; bag `amounts` uses prop save data; costs use shared balance | host | machine/cup state replay; props and records in snapshot | implemented; needs build and two-peer verification |
| Email, daily task | host-appended emails, peer-symmetric delete; the host's task state | host | save transfer plus a prime | built |
| Deployed hook and rope | existence, flight, the head pose and the reel while the thrower holds it; the handover to the host when both ends anchor, and the anchor itself; the tie, which exists on the host alone, so a prop any peer's hook drags streams to everyone and two hooks on one prop are two host constraints | peer while held, arbiter at the anchor, host after; the host for every constraint | keepalive while held; the save plus a ready-edge replay once anchored; a mirror re-bites from the state it is rebuilt from | built |
| Lamp posts | not synced: lockstep from the shared day and night cycle | local | none, by design | built |
@@ -58,6 +60,7 @@ host validates and commits contested writes), `local` (never shared).
| Doors, keypads, locks | a door's state, sent at its own verbs; a keypad's verbs, replayed from the host, and the state each chain settles on; a client's own door press, hit and pry and its keypad entries run on the host | host | snapshot | tested; a pry, a keycard and a pass changer built |
| Lights and light groups | switch state; the group's live state | presser; host for the group | snapshot | built |
| The power grid | the panel's breakers and lockout; each generator's break, wear, upgrades and repair puzzle | host; a client's presses, Activate presses, installs, hits and puzzle inputs are ops | snapshot | built |
+| Power cords and sockets | a corded prop's socket link, including plug and unplug changes | presser; host relays | host snapshot | built |
| Turbine, grime, windows, appliances | the float, the decrease-only cleanliness, the one-bit states | presser or host | snapshot | built; windows tested |
| The bay window's dirt | each sponge dab on its render target: the pixel, the edge, the brush's opacity and colour | presser, relayed by the host | the host's transferred save | built |
| Device occupancy | who is using a device | arbiter | snapshot of the table | works |
@@ -73,7 +76,7 @@ host validates and commits contested writes), `local` (never shared).
| Disc slots | the slot a laptop or a signal server holds a disc in, and the disc that comes back out of one | host owns the slot; the peer whose game changed it reports the outcome | every device's slot at the barrier | tested |
| Shop orders | the client names a row, the host performs and prices it; the delivery queue is the host's, mirrored | arbiter | a reset and every queued order | built |
| ATV | the driver authors the pose; a non-driving peer runs the rig natively and is corrected; condition (tyres, fuel, health) travels | driver, host for the rest | snapshot | tested; eject and configuration intents not built |
-| Sleep | the sleep tally | arbiter | joins awake | built |
+| Sleep | each peer's rest state and the host's sleeping flag | each peer for its own state; the host relays | the host's flag and every peer not awake | built |
| Player inventory | per-peer, persisted by the host per player identity; the contents never cross the wire | local; host stores | seeded before the world | tested |
## Enforced without a packet
diff --git a/docs/ui.md b/docs/ui.md
index db62967a2..ba72482b3 100644
--- a/docs/ui.md
+++ b/docs/ui.md
@@ -77,7 +77,7 @@ The names are data, never code (`coop/thanks/thanks_list`). One text file,
is added or taken out by publishing the file, with no release. Nobody expires: a name stays for
as long as its line does. Two copies are weighed, the build's and what the master last said: the
higher `revision` wins and the master takes a tie, so a master left holding an old file cannot
-take names away from a newer build. The cache beside the executable (`multivoid_thanks.txt`) is
+take names away from a newer build. The cache in the mod's own folder (`multivoid_thanks.txt`) is
only a memo of the master's last word: it names the master it came from, a new answer replaces
it whatever its revision, and "no list" erases it, so a copy a master once served never outlives
that master's say. The fetch rides the browser's open beside the update check and never the
diff --git a/docs/vehicles.md b/docs/vehicles.md
index 5e57fdb08..d54471ef7 100644
--- a/docs/vehicles.md
+++ b/docs/vehicles.md
@@ -47,6 +47,10 @@ 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, 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/docs/versioning.md b/docs/versioning.md
index f892682bc..5d3d11f98 100644
--- a/docs/versioning.md
+++ b/docs/versioning.md
@@ -29,6 +29,25 @@ Join and is compared on the host, which refuses with a reason the client shows;
marks a mismatched lobby amber before a connect is tried. An old cohort keeps playing among
itself, and the update check on the main menu informs and never gates.
+## Which game is running
+
+The pair says which game the mod was made for, not which game it runs on: a player can update
+the game and keep the mod. So the mod reads the running build at start from the game's own
+files -- the executable's link stamp and the main pak's index hash, both read without hashing a
+file (the file size names nothing: one build measured three sizes, packed and unpacked) -- and
+matches them against the builds it knows (`ue_wrap/facts/game_builds.inc`). On a game it was not
+made for, it warns and never forbids: a popup names both versions, with Check for an update
+(the releases page) and Continue anyway, which is remembered for that build; the menu's version
+label reads "(untested game version)"; and the first line of `multivoid-compat-report.txt` says
+what runs. Where the engine itself does not read as the mod expects, the mod stands down instead,
+and its dialog names both versions too.
+
+The build each side runs also rides the Join, after the game target: one Multivoid on two
+different games is refused (MV-J07 on the joiner, MV-H22 on the host), while two players who both
+run the same untested build may play. The lobby lists it, so the browser marks a host on another
+game build and refuses it before a connect. The update check answers per game version: a master
+lists every game's newest release, and the mod offers the one for the game it runs.
+
## What a game update breaks
Almost all of the mod resolves the game by name at runtime and is version-agnostic. The
@@ -80,8 +99,10 @@ reports, or two SDK dumps, and names the constant each change belongs to.
addresses, a disassembler confirms each, and a unique byte pattern with wildcarded
displacements replaces the old one. An open-source signature resolver for Unreal executables
is a useful independent first answer.
-6. Update the identity: the game target, the expected executable size, the build number, and
- the game target named on [install.md](install.md), which a lint checks in CI.
+6. Update the identity: the game target, the new build's row (its link stamp and pak hash, from
+ the boot's `game-build:` line) with the profile's `kTargetBuild` naming it, the last Multivoid
+ build for the previous game version, the build number, and the game target named on
+ [install.md](install.md), which a lint checks in CI.
7. Run the gates in order: the health check all green, the config self-test, the two-peer smoke,
the launcher's scenarios for the systems the diff said moved, and a play session by a person.
8. Write down what it cost. Nothing here has been done for real yet: the mod has only ever run
@@ -110,5 +131,5 @@ built from.
|---|---|
| the pair | `src/votv-coop/CMakeLists.txt`, `coop/net/protocol.h`, `coop/session/player_handshake_version.cpp` |
| the version surface | `ue_wrap/core/sdk_profile.h`, `ue_wrap/core/sdk_profile_names.h` |
-| the checks | `ue_wrap/core/reflection` (the boot health check), `harness/sdk_check` |
+| the checks | `ue_wrap/core/reflection` (the boot health check), `harness/sdk_check` (names), `harness/fingerprint_census` (logic, [bytecode-fingerprint.md](bytecode-fingerprint.md)), `harness/compat_report` (their one report) |
| the loader | `.github/ci/abi_gate.py`, `loader/cppmod_entry.cpp` |
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",
diff --git a/server/README.md b/server/README.md
index 0938f9d35..4e2d5a49c 100644
--- a/server/README.md
+++ b/server/README.md
@@ -10,12 +10,22 @@ them.
| bin | default port | env |
|---|---|---|
-| `coop-master` | `COOP_MASTER_PORT` (10001) | `COOP_TURN_SECRET`*, `COOP_SIGNALING_TOKEN`*, `COOP_SIGNALING_URL`, `COOP_STUN_URI`, `COOP_TURN_URI` |
+| `coop-master` | `COOP_MASTER_PORT` (10001) | `COOP_TURN_SECRET`*, `COOP_SIGNALING_TOKEN`*, `COOP_DOMAIN`*, `COOP_MASTER_NAME_ALIASES`, `COOP_MIN_LISTED_PROTO`, `COOP_BLOCKED_IDENTITIES`, `COOP_SIGNALING_URL`, `COOP_STUN_URI`, `COOP_TURN_URI` |
| `coop-signaling` | `COOP_SIGNALING_PORT` (10000) | `COOP_SIGNALING_TOKEN`* |
`*` required: the process refuses to start (exit 1) without it. All secrets are env-only;
nothing is hardcoded, and it stays that way.
+The master's names: `COOP_DOMAIN` is required and is the name its clients dial, written as a
+bare host (`master.example.org` or an IPv4 literal: no scheme, path, login, port or IPv6 form; the
+master refuses to start without it or on anything else). A lobby proof is signed for that name, so the `net.masters` entry a player adds
+must dial the same host. `COOP_MASTER_NAME_ALIASES` is an optional comma list of other names it
+answers to, never a substitute for it (an IPv4 literal, a dev host): each entry follows the same rules
+bar an optional `:port`, which is stripped, and an invalid entry stops the master at start; names are
+trimmed and lower-cased, and a loopback alias is dropped with a warning. `COOP_MIN_LISTED_PROTO` (default 0) is the
+floor: a create from a build below it is refused, and a value that is not a decimal number
+stops the master at start.
+
## Build
```
@@ -36,9 +46,29 @@ limits and per-IP caps are the wrong fixture for many synthetic rows from one ad
## The wire
-- **Endpoints**: `/v1/host`, `/v1/heartbeat`, `/v1/leave`, `/v1/visibility`, `/v1/join`,
+- **Endpoints**: `/v1/host/challenge`, `/v1/host`, `/v1/heartbeat`, `/v1/leave`, `/v1/visibility`, `/v1/join`,
`/v1/lobbies`, `/v1/latest`, `/v1/thanks`, `/healthz`. The mod's side is
`coop/net/lobby_client` and `coop/net/lobby_announcer`.
+- **The lobby proof**: a create is two calls. `POST /v1/host/challenge` (body `{}`) answers a
+ `nonce`; `POST /v1/host` then carries `nonce` and `proof`, the host's Ed25519 signature over
+ `"multivoid-lobby-announce-v1" || identity || nonce || master_name` (`src/lobby_proof.rs`, the
+ shared vector in `tests/vectors/lobby_proof.json`). The nonce is stateless: its expiry, random
+ bytes and an HMAC (a secret drawn when the master starts) over the caller's address bucket, so
+ issuing stores nothing and no flood of challenges can fill the master; only a spent nonce is
+ kept, until it expires (single use; a full set, 65536 (sized to the master's memory), answers
+ `503 busy` and never forgets a nonce). A restart voids
+ outstanding nonces and the host asks again. The challenge has its own rate class. A build at or
+ above `MIN_PROVED_PROTO` (`src/version_gate.rs`) without a valid proof is `409 proof-required`;
+ a proof that is present must verify (`400 bad-proof`); a nonce that is not this master's for
+ this address, or is spent or expired, is `410 nonce-expired`; a build below
+ `COOP_MIN_LISTED_PROTO` is `403 build-too-old`; an identity in `COOP_BLOCKED_IDENTITIES` (comma
+ list of `gen:` keys; an entry of another shape refuses the start) is `403 blocked` and never listed (asked after the proof and the floor, so only a create that proves its key, or a build that need not, learns it).
+ A valid proof marks the row `proved`, and a
+ proved create for a key already listed replaces that key's row (new session and token). When
+ the table is full a create evicts the stalest unproved row, then the stalest proved one; an
+ unproved create is refused (`429`) when every row is proved. `/v1/lobbies` rows carry
+ `identity` and `proved`. The host name a client signs is the host of its master URL, by the
+ rules of `canon_host_name` (`src/common.rs`; cases in `tests/vectors/master_names.json`).
- **The thanks list**: `/v1/thanks` answers `{"text": ...}` with the file `COOP_THANKS_FILE`
names (default `/etc/coop-thanks.txt`), a copy of `src/votv-coop/assets/thanks/thanks.txt`.
Re-read at most every thirty seconds, capped at 64 KiB, 404 when it cannot be served whole.
@@ -58,10 +88,13 @@ limits and per-IP caps are the wrong fixture for many synthetic rows from one ad
Rightmost `X-Forwarded-For` from a loopback proxy only, so the rate-limit and target address
cannot be spoofed; constant-time token compare; an opaque `lobbyId` against a secret `sessionId`;
control-character strip and codepoint clamp on every string; per-(address, class) sliding-window
-rate limits; a global LRU and per-address lobby caps; a `LOBBY_TTL` sweep; bounded header and
+rate limits (the table of them holds 50000 keys and, full, forgets the least recently seen rather
+than refuse a new address; `GET /v1/lobbies` has its own class, 120 per minute); 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, 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
@@ -70,9 +103,10 @@ 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
- constants that name the newest build for the update check, which is informational and never
- a gate; `LOBBY_TTL` sits beside them.
+- `COOP_LATEST_GAMES` (the newest release per game version, `::,...`) and
+ `COOP_LATEST_URL` in the master's environment name the builds for the update check, which is
+ informational and never a gate; the release ritual writes the list from the release ledger.
+ `LOBBY_TTL` sits beside `LATEST_URL` in the source.
diff --git a/server/src/admission.rs b/server/src/admission.rs
index 7ebe515cf..21b2b9a44 100644
--- a/server/src/admission.rs
+++ b/server/src/admission.rs
@@ -8,9 +8,9 @@
//! 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 std::collections::HashMap;
-use std::sync::Mutex;
+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};
/// 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,192 @@ 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 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`, `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.
+ 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,
+}
+
+#[derive(Default)]
+struct History {
+ per_addr: HashMap,
+ last_sweep: Option,
+ refusals: Refusals,
+ // Newcomers admitted untracked because the table was full.
+ untracked: Refusals,
+}
+
+#[derive(Default)]
+struct Recent {
+ connects: VecDeque,
+ ignored_until: Option,
+}
+
+/// 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 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 (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 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: {} (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
+});
+
+impl ConnectHistory {
+ 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 {
+ 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
+ }
+
+ /// 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. 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() >= 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, None);
+ }
+ 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, 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);
+ // 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() };
+ format!(
+ "[{ip}] connect flood: more than {} inside {} s, ignored for {} s{tail}",
+ self.max,
+ self.period.as_secs(),
+ self.ban.as_secs()
+ )
+ });
+ (false, line)
+ }
+
+ /// 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 +311,69 @@ impl Drop for Slot<'_> {
#[cfg(test)]
mod tests {
- use super::{Pool, REFUSAL_LOG_EVERY};
- use std::time::Instant;
+ 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]
+ fn a_connect_flood_is_ignored_for_the_ban_and_alone() {
+ 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");
+ 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), 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");
+ // 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 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, 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");
+ 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");
+ }
+
+ #[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() {
diff --git a/server/src/bin/master.rs b/server/src/bin/master.rs
index 74537d8f3..861c1a16e 100644
--- a/server/src/bin/master.rs
+++ b/server/src/bin/master.rs
@@ -11,16 +11,17 @@
//! ever held across an await — while still using the multi-thread tokio runtime.
-use coop_server::admission::Pool;
-use coop_server::common::{clamp_str, env_int, env_str, log};
+use coop_server::admission::{Pool, CONNECTS};
+use coop_server::common::{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,
- LATEST_MOD, LATEST_PROTO, LATEST_URL, MAX_VERSION,
+ dispatch_post, h_browse, latest_answer, lock_state, parse_latest_games, resolve_client_ip, sweeper,
+ LATEST_URL,
};
-use coop_server::master_config::CFG;
+use coop_server::master_config::{blocked_faults, config_faults, CFG};
use coop_server::thanks::{thanks_answer, Answer};
use coop_server::tls;
use serde_json::{json, Value};
@@ -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,102 +44,55 @@ 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 (method, path, content_length, client_ip) = match parse_head(&head) {
+ Ok(h) => (
+ h.method.to_string(),
+ h.path.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",
+ HeadReject::BadRequestLine => "bad request line",
+ };
+ write_response(&mut stream, 400, &json_bytes(&json!({ "error": error }))).await;
return;
}
};
+ // A connection holds its head or its body, never both.
+ drop(head);
+ let (method, path) = (method.as_str(), path.as_str());
- 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);
-
- // ---- 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 = headers
- .get("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 ----
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. The list has its own rate class.
+ let body = {
let mut state = lock_state();
- lobbies_snapshot(&mut state)
+ h_browse(&mut state, &client_ip)
};
- 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;
+ match body {
+ Some(body) => write_response(&mut stream, 200, &body).await,
+ None => write_response(&mut stream, 429, &json_bytes(&json!({"error": "rate"}))).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(|| {
- (
- env_int("COOP_LATEST_PROTO", LATEST_PROTO),
- env_str("COOP_LATEST_MOD", LATEST_MOD),
- env_str("COOP_LATEST_URL", LATEST_URL),
- )
- });
- let (proto, mod_str, url) = &*LATEST;
- write_response(
- &mut stream,
- 200,
- &json_bytes(&json!({"proto": proto, "mod": mod_str, "url": url})),
- )
- .await;
+ write_response(&mut stream, 200, &LATEST).await;
} else if method == "GET" && path == "/v1/thanks" {
// The thanks list the mod's main menu rolls. Three answers, and the mod acts on the
// difference: the text; 404, the only answer that lets a client retire the copy it cached
@@ -157,18 +110,55 @@ 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"))),
+ }
+}
+
+// The release record, from the env once (see the LATEST_URL comment in lobby.rs). A list entry that does not parse,
+// or names a game a second time, is dropped and named; an empty list is said too -- never silent.
+static LATEST: LazyLock> = LazyLock::new(|| {
+ let (games, refused) = parse_latest_games(&env_str("COOP_LATEST_GAMES", ""));
+ for entry in &refused {
+ log(&format!("WARN: COOP_LATEST_GAMES entry '{}' refused (want ::, each game once)", entry));
+ }
+ if games.is_empty() {
+ log("WARN: COOP_LATEST_GAMES names no release -- /v1/latest answers with no record");
+ }
+ json_bytes(&latest_answer(&games, &env_str("COOP_LATEST_URL", LATEST_URL)))
+});
+
#[tokio::main]
async fn main() {
+ // Built now, so its settings are logged at start rather than at the first connect.
+ LazyLock::force(&CONNECTS);
+ // The same for the release record: a typo in COOP_LATEST_GAMES is named at the restart that brought it.
+ LazyLock::force(&LATEST);
// 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");
@@ -178,6 +168,20 @@ async fn main() {
log("FATAL: COOP_SIGNALING_TOKEN not set -- clients could not reach signaling");
std::process::exit(1);
}
+ // A name missing, or a name or a floor of the wrong shape, would start a master no client can use.
+ let mut faults = config_faults(
+ &env_str("COOP_DOMAIN", ""),
+ &env_str("COOP_MASTER_NAME_ALIASES", ""),
+ &env_str("COOP_MIN_LISTED_PROTO", ""),
+ );
+ // A block-list entry that is not a key identity would match no host and say nothing.
+ faults.extend(blocked_faults(&env_str("COOP_BLOCKED_IDENTITIES", "")));
+ for fault in &faults {
+ log(&format!("FATAL: {fault}"));
+ }
+ if !faults.is_empty() {
+ std::process::exit(1);
+ }
// Resolve the TLS decision BEFORE binding anything: with COOP_REQUIRE_TLS=1
// and no cert this exits, and it must do so without having briefly bound a
@@ -193,10 +197,13 @@ async fn main() {
}
};
log(&format!(
- "master listening on {addr} (signaling={} stun={} turn={})",
+ "master listening on {addr} (signaling={} stun={} turn={} names={} min_listed_proto={} blocked={})",
if CFG.signaling_url.is_empty() { "?" } else { &CFG.signaling_url },
if CFG.stun_uri.is_empty() { "?" } else { &CFG.stun_uri },
- if CFG.turn_uri.is_empty() { "off" } else { "on" }
+ if CFG.turn_uri.is_empty() { "off" } else { "on" },
+ CFG.public_names.join(","),
+ CFG.min_listed_proto,
+ CFG.blocked_identities.len()
));
tokio::spawn(sweeper());
@@ -242,6 +249,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;
@@ -271,12 +281,15 @@ 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();
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}]")),
@@ -328,6 +341,73 @@ 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 (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");
+ 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 the_challenge_route_answers_a_nonce() {
+ let (status, body) = ask(post("/v1/host/challenge", b"{}")).await;
+ assert_eq!(status, 200, "the challenge was refused: {body}");
+ let answer: serde_json::Value = serde_json::from_str(&body).unwrap();
+ let nonce = answer["nonce"].as_str().expect("no nonce");
+ assert!(nonce.len() == 64 && nonce.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)));
+ }
+
+ #[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 10e759ea9..9ce9bed14 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;
@@ -91,27 +91,29 @@ 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 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)
-// 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. 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; 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 = 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, 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: 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. 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);
/// Bounded, cancel-safe line reader over the connection's read half. On `select!`
@@ -146,11 +148,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);
}
@@ -395,24 +401,13 @@ 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;
}
- 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 +420,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
@@ -441,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);
@@ -492,6 +509,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);
@@ -517,12 +537,15 @@ 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;
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}]")),
@@ -659,6 +682,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};
@@ -673,6 +718,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]
diff --git a/server/src/common.rs b/server/src/common.rs
index 033423b1f..9ecf48546 100644
--- a/server/src/common.rs
+++ b/server/src/common.rs
@@ -125,9 +125,10 @@ fn is_display_safe(c: char) -> bool {
/// A peer identity as GNS renders a 32-byte `GenericBytes` one: `gen:` + 64
/// lowercase hex. Since b144 a host publishes its durable PUBLIC KEY here and
-/// joiners dial exactly this string, so the master's job is to store it verbatim
-/// -- it neither mints it nor can verify it, and it must not silently accept a
-/// shape that would dial nobody. Lowercase is required, not merely preferred:
+/// joiners dial exactly this string. The master stores it verbatim and never mints it; this
+/// shape check cannot tell whether the sender holds the key (the announce proof in
+/// `lobby_proof` does that), but it must not accept a shape that would dial nobody.
+/// Lowercase is required, not merely preferred:
/// `[V]` GNS hashes and byte-compares identity strings and its own parser rejects
/// uppercase prefixes for that reason, so `GEN:AB..` would register under one
/// spelling and be dialled under another.
@@ -158,6 +159,36 @@ pub fn build_claim_shape_ok(sha: &str, key: i64, sig: &str) -> bool {
}
}
+/// A host name as the master compares it with the name a lobby proof was signed for: trimmed,
+/// lower-case, any login (`name@`) stripped, a trailing `:port` stripped, an IPv6 literal kept with its
+/// brackets, one trailing dot stripped. A name with a non-ASCII byte is empty (no master the
+/// client can dial has one). The client derives the name it signs from the URL it dialled by the
+/// same rule (`server/tests/vectors/master_names.json` holds the shared cases).
+pub fn canon_host_name(s: &str) -> String {
+ let s = s.trim();
+ if !s.is_ascii() {
+ return String::new();
+ }
+ let s = s.rsplit_once('@').map_or(s, |(_, host)| host).to_ascii_lowercase();
+ let host = if s.starts_with('[') {
+ match s.find(']') {
+ Some(end) => s[..=end].to_string(),
+ None => s,
+ }
+ } else {
+ match s.rsplit_once(':') {
+ Some((host, port)) if !host.contains(':') && !port.is_empty() && port.bytes().all(|b| b.is_ascii_digit()) => {
+ host.to_string()
+ }
+ _ => s,
+ }
+ };
+ match host.strip_suffix('.') {
+ Some(bare) => bare.to_string(),
+ None => host,
+ }
+}
+
/// Decode an EXACTLY-`N`-byte lowercase-hex string. Returns `None` on any wrong
/// length, odd length, or non-hex byte -- there is no partial success, because
/// every caller here is deciding whether to trust a remote peer and a
@@ -230,6 +261,38 @@ mod tests {
assert_eq!(ip_bucket("2001:db8:1:2::1"), "2001:db8:1:2::/64");
}
+ #[test]
+ fn a_host_name_is_compared_lower_case_without_its_port() {
+ assert_eq!(canon_host_name(" Master.Multivoid.DEV "), "master.multivoid.dev");
+ assert_eq!(canon_host_name("master.multivoid.dev:10443"), "master.multivoid.dev");
+ assert_eq!(canon_host_name("203.0.113.7:10443"), "203.0.113.7");
+ assert_eq!(canon_host_name("[2001:DB8::1]:10443"), "[2001:db8::1]");
+ assert_eq!(canon_host_name("[2001:db8::1]"), "[2001:db8::1]");
+ assert_eq!(canon_host_name("2001:db8::1"), "2001:db8::1", "a bare v6 literal has no port to strip");
+ assert_eq!(canon_host_name("host:"), "host:", "an empty port is not stripped");
+ assert_eq!(canon_host_name("host:80x"), "host:80x");
+ assert_eq!(canon_host_name("alice:pw@master.multivoid.dev:10443"), "master.multivoid.dev");
+ assert_eq!(canon_host_name("master.multivoid.dev."), "master.multivoid.dev");
+ assert_eq!(canon_host_name("master.multivoid.dev.:10443"), "master.multivoid.dev");
+ assert_eq!(canon_host_name("b\u{fc}cher.example:10443"), "", "a non-ASCII host has no name");
+ assert_eq!(canon_host_name("user@b\u{fc}cher.example"), "");
+ }
+
+ // The case table the client's selftest checks as well (`HostName` in http_client.cpp): a URL as
+ // a player writes it, and the name the master compares. The master receives the host part.
+ #[test]
+ fn the_shared_url_to_name_table_holds() {
+ const TABLE: &str = include_str!("../tests/vectors/master_names.json");
+ let rows: Vec = serde_json::from_str(TABLE).unwrap();
+ assert!(rows.len() >= 10, "the table lost rows");
+ for row in rows {
+ let url = row["url"].as_str().expect("url");
+ let after_scheme = url.split_once("://").map_or(url, |(_, rest)| rest);
+ let authority = after_scheme.split('/').next().unwrap();
+ assert_eq!(canon_host_name(authority), row["name"].as_str().expect("name"), "{url}");
+ }
+ }
+
#[test]
fn ct_eq_basic() {
assert!(ct_eq(b"abc", b"abc"));
diff --git a/server/src/http_transport.rs b/server/src/http_transport.rs
index e9cb4837e..2cf4426bd 100644
--- a/server/src/http_transport.rs
+++ b/server/src/http_transport.rs
@@ -12,11 +12,24 @@ 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;
-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 largest body a client of ours sends is the host announce (coop/net/lobby_announcer.cpp):
+/// about 2 KiB at its worst, a name at the native host window's 96-byte cap
+/// (host_session_settings.cpp:59) fully escaped (576 bytes), plus the identity, the build claim
+/// and the lobby proof's nonce and signature (about 450 hex digits, with their field names); 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. 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; 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;
+/// 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.
@@ -66,14 +79,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 +99,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 +117,89 @@ 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 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 = 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();
+ 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), ("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");
+ // 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/lib.rs b/server/src/lib.rs
index 767bd19ec..eed09c973 100644
--- a/server/src/lib.rs
+++ b/server/src/lib.rs
@@ -4,7 +4,9 @@ pub mod common;
pub mod http_transport;
pub mod ice;
pub mod lobby;
+pub mod lobby_proof;
pub mod master_config;
+pub mod rate_table;
pub mod registration;
pub mod thanks;
pub mod tls;
diff --git a/server/src/lobby.rs b/server/src/lobby.rs
index 1100b028a..0c123e4d5 100644
--- a/server/src/lobby.rs
+++ b/server/src/lobby.rs
@@ -1,17 +1,21 @@
//! The lobby domain: what a lobby is, the state every request mutates, and the handlers behind
-//! `/v1/host`, `/v1/heartbeat`, `/v1/leave`, `/v1/visibility` and `/v1/join`, plus the browse
-//! snapshot and the sweeper that reaps what stopped heartbeating.
+//! `/v1/heartbeat`, `/v1/leave`, `/v1/visibility` and `/v1/join`, plus the browse snapshot and the
+//! sweeper that reaps what stopped heartbeating. The create (`/v1/host/challenge`, `/v1/host`) is in `host`.
-use crate::common::{build_claim_shape_ok, clamp_str, ct_eq, identity_shape_ok, ip_bucket, log, token_hex, token_urlsafe};
-use crate::version_gate::version_gate_env;
+use crate::common::{build_claim_shape_ok, clamp_str, ct_eq, ip_bucket, log, token_hex, token_urlsafe};
use crate::ice::{heartbeat_turn, ice_block, turn_creds, HostTurn, TURN_TTL_HOST, TURN_TTL_JOIN};
+use crate::lobby_proof::Nonces;
use crate::master_config::CFG;
+use crate::rate_table::RateTable;
use serde_json::{json, Value};
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::{LazyLock, Mutex, MutexGuard};
use std::time::{Duration, Instant};
+pub mod host;
+use host::{h_challenge, h_host, HostCtx};
+
// 90s = 3 missed 30s heartbeats before a lobby is reaped. Was 300s (ghost-lobby bug:
// a TASK-KILLED host sends no /v1/leave, so its dead entry lingered up to the TTL; a
// dead host was seen at 237s/297s age). Lowered 2026-07-16 per the user's go.
@@ -20,35 +24,97 @@ pub const LOBBIES_CACHE_TTL: Duration = Duration::from_secs(5);
pub const MAX_LOBBIES_PER_IP: usize = 8;
pub const MAX_LOBBIES_GLOBAL: usize = 1000;
-// Hard cap on distinct rate-limit buckets, so a source-diverse flood can't grow the
-// `rate` map toward MemoryMax between the 30s sweeps (security audit 2026-07-16, M3).
-// With /64 IPv6 coarsening a flood needs distinct routable prefixes to add keys, so
-// this is a belt-and-suspenders ceiling; when hit, new buckets are refused (the
-// flooding prefixes are simply treated as rate-limited).
+// Hard cap on distinct rate-limit keys, so a source-diverse flood can't grow the `rate`
+// table toward MemoryMax between the 30s sweeps (security audit 2026-07-16, M3). At the cap
+// a NEW key is admitted and the least recently seen one forgotten (`rate_table`): the table
+// degrades to forgetting old flooders and never refuses an address it does not hold. MTA bounds
+// its mitigation otherwise: once its own table holds 100 flooding entries it stops counting
+// queries per address and answers the rest (reference/mtasa-blue/Server/mods/deathmatch/logic/
+// ASE.cpp:183); this table keeps counting every key it holds.
pub const MAX_RATE_KEYS: usize = 50_000;
// per-(IP, class) sliding-window rate limits: (window, max events).
+pub const RL_CHALLENGE: (Duration, usize) = (Duration::from_secs(60), 10);
pub const RL_CREATE: (Duration, usize) = (Duration::from_secs(60), 10);
pub const RL_JOIN: (Duration, usize) = (Duration::from_secs(60), 20);
pub const RL_MUTATE: (Duration, usize) = (Duration::from_secs(60), 240);
+// GET /v1/lobbies: the 5 s snapshot cache bounds the work, this bounds the bytes an address can pull.
+// An open browser fetches every 5 s (12 a minute), so 120 leaves room for ten players behind one address.
+pub const RL_BROWSE: (Duration, usize) = (Duration::from_secs(60), 120);
pub const MAX_NAME: usize = 63;
pub const MAX_WORLD: usize = 39;
pub const MAX_VERSION: usize = 23;
pub const MAX_GAME: usize = 23; // v122: the host's VOTV game target ("0.9.0n")
+pub const MAX_GAME_BUILD: usize = 25; // b222: the build the host runs, "-"
-// latest released mod, served by /v1/latest. DEPLOY CONFIG, not code: overridable via
-// COOP_LATEST_PROTO / COOP_LATEST_MOD / COOP_LATEST_URL in /etc/coop-master.env, so a
-// release bump is an env edit + `systemctl restart coop-master` -- no rebuild.
-// v122 (2026-07-19): the compiled-in default is proto 0 = "no released record" --
-// the client treats proto<=0 as NO VERDICT (silent identity label; it can never
-// fabricate a stale "latest" or an update nag). ZERO releases exist yet; the first
-// real release sets COOP_LATEST_PROTO (its build number) + COOP_LATEST_MOD (the
-// Paper-pair display tag, e.g. "0.9.0n-b130") per the release checklist.
-pub const LATEST_PROTO: i64 = 0;
-pub const LATEST_MOD: &str = "";
+// The latest released mod, served by /v1/latest. DEPLOY CONFIG, not code: COOP_LATEST_GAMES and
+// COOP_LATEST_URL in /etc/coop-master.env, so a release is an env edit + `systemctl restart coop-master`
+// -- no rebuild. The release ritual writes the whole list from the release ledger (publish.ps1 prints it,
+// release_master_latest.sh writes it on every master, verify_latest.ps1 checks it). No list = no record:
+// the client treats proto<=0 as NO VERDICT, so it never fabricates a "latest" or an update nag.
pub const LATEST_URL: &str = "https://github.com/VOTV-MP/Multivoid/releases";
+/// The game every client older than `games` targets: all releases before b222 are for 0.9.0n. Their
+/// legacy `proto`/`mod` are this game's entry, so that cohort is never told to update to a Multivoid made
+/// for a game it does not have. A historical fact, frozen.
+pub const LEGACY_GAME: &str = "0.9.0n";
+
+/// The latest release per game version, served by /v1/latest as `games` (b222):
+/// `COOP_LATEST_GAMES=::,...` -- e.g. `0.9.0n:221:0.9.0n b221,0.10.0:230:0.10.0 b230`; the
+/// tag keeps the " b" shape and may hold spaces, never `,` or `:`. The mod picks the entry for the
+/// game it runs.
+#[derive(Debug, Clone, PartialEq)]
+pub struct LatestGame {
+ pub game: String,
+ pub proto: i64,
+ pub tag: String,
+}
+
+/// Parse `COOP_LATEST_GAMES`. An entry that does not parse, or names a game an earlier entry named, is dropped
+/// and named in the second list (the caller logs it once): a typo in the deploy config costs that entry, never the
+/// others. Empty -> no entries.
+pub fn parse_latest_games(spec: &str) -> (Vec, Vec) {
+ let mut games = Vec::new();
+ let mut refused = Vec::new();
+ for entry in spec.split(',').map(str::trim).filter(|e| !e.is_empty()) {
+ let parts: Vec<&str> = entry.splitn(3, ':').collect();
+ let ok = parts.len() == 3
+ && crate::version_gate::game_shape_ok(parts[0])
+ && parts[1].parse::().map(|p| p > 0 && p <= 65535).unwrap_or(false)
+ && !parts[2].is_empty()
+ && parts[2].len() <= MAX_VERSION
+ && parts[2].bytes().all(|b| b != b':' && (b == b' ' || b.is_ascii_graphic()));
+ if ok && !games.iter().any(|g: &LatestGame| g.game == parts[0]) {
+ games.push(LatestGame {
+ game: parts[0].to_string(),
+ proto: parts[1].parse().unwrap_or(0),
+ tag: parts[2].to_string(),
+ });
+ } else {
+ refused.push(entry.to_string());
+ }
+ }
+ (games, refused)
+}
+
+/// /v1/latest's body: every game's latest under `games` (absent when none), and the legacy `proto`/`mod`
+/// from the LEGACY_GAME entry (0 and "" without one -- no verdict for the old cohort). `url` as given.
+pub fn latest_answer(games: &[LatestGame], url: &str) -> Value {
+ let legacy = games.iter().find(|g| g.game == LEGACY_GAME);
+ let mut body = json!({
+ "proto": legacy.map_or(0, |g| g.proto),
+ "mod": legacy.map_or("", |g| g.tag.as_str()),
+ "url": url,
+ });
+ if !games.is_empty() {
+ body["games"] = Value::Array(
+ games.iter().map(|g| json!({"game": g.game, "proto": g.proto, "mod": g.tag})).collect(),
+ );
+ }
+ body
+}
+
pub const TRUSTED_PROXY_PEERS: [&str; 2] = ["127.0.0.1", "::1"];
pub static STATE: LazyLock> = LazyLock::new(|| Mutex::new(MasterState::new()));
@@ -68,9 +134,11 @@ pub struct Lobby {
pub lobby_id: String,
pub token: String,
pub host_identity: String,
+ pub proved: bool, // the create proved its host holds the key `host_identity` names
pub name: String,
pub version: String,
pub game: String, // v122: host's VOTV game target ("" = pre-field host)
+ pub game_build: String, // b222: the game build the host runs ("" = pre-field host)
pub proto: i64,
pub world: String,
pub locked: bool,
@@ -133,19 +201,38 @@ impl LobbyLinks {
}
impl Lobby {
+ /// A row with its ids drawn (the tests' way to put a row in a table).
+ #[cfg(test)]
fn new(ip: &str) -> Lobby {
+ let mut lo = Lobby::draft(ip);
+ lo.draw_ids();
+ lo
+ }
+
+ /// The three random ids: session, public lobby id, host token.
+ fn draw_ids(&mut self) {
+ self.session_id = token_hex(16);
+ self.lobby_id = token_hex(8);
+ self.token = token_urlsafe(24);
+ }
+
+ /// A row with no ids yet. `h_host` fills it from the body and draws the ids only after
+ /// every refusal has passed, so a refused create pays for no randomness.
+ fn draft(ip: &str) -> Lobby {
Lobby {
- session_id: token_hex(16),
- lobby_id: token_hex(8),
- token: token_urlsafe(24),
+ session_id: String::new(),
+ lobby_id: String::new(),
+ token: String::new(),
// Empty until `/v1/host` supplies the host's own public key, which it
// must (see h_host). The master no longer mints a routing name: a name
// it asserts on a host's behalf is one nobody can prove, and since
// 2026-08-29 the relay registers only proved keys.
host_identity: String::new(),
+ proved: false,
name: String::new(),
version: String::new(),
game: String::new(),
+ game_build: String::new(),
proto: 0,
world: String::new(),
locked: false,
@@ -168,13 +255,13 @@ impl Lobby {
pub struct MasterState {
pub lobbies: HashMap, // sessionId -> Lobby
pub lobby_by_public: HashMap, // lobbyId -> sessionId
- pub rate: HashMap>, // "ipbucket|class" -> recent timestamps
+ pub rate: RateTable, // "ipbucket|class" -> recent timestamps, capped at MAX_RATE_KEYS
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>,
+ pub nonces: Nonces, // minted by /v1/host/challenge, spent by /v1/host
}
impl MasterState {
@@ -182,9 +269,9 @@ impl MasterState {
MasterState {
lobbies: HashMap::new(),
lobby_by_public: HashMap::new(),
- rate: HashMap::new(),
+ rate: RateTable::new(MAX_RATE_KEYS),
+ nonces: Nonces::new(),
cache_t: None,
- cache_rows: Arc::new(Vec::new()),
cache_all_body: Arc::new(Vec::new()),
}
}
@@ -264,15 +351,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
@@ -284,19 +371,16 @@ pub fn resolve_client_ip(peer_ip: &str, headers: &HashMap) -> St
pub fn rate_ok(state: &mut MasterState, ip: &str, cls: &str, window: Duration, limit: usize) -> bool {
let key = format!("{}|{}", ip_bucket(ip), cls);
- let now = Instant::now();
- // Bound the map: refuse a brand-new bucket once at the ceiling (audit M3) so a
- // source-diverse flood can't outgrow the 30s sweeper. Existing buckets still work.
- if !state.rate.contains_key(&key) && state.rate.len() >= MAX_RATE_KEYS {
- return false;
- }
- let bucket = state.rate.entry(key).or_default();
- bucket.retain(|ts| now.duration_since(*ts) < window);
- if bucket.len() >= limit {
- return false;
+ state.rate.hit(&key, Instant::now(), window, limit)
+}
+
+/// `GET /v1/lobbies`: the cached list body for an address within its read rate, else None (the
+/// caller answers `429`).
+pub fn h_browse(state: &mut MasterState, ip: &str) -> Option>> {
+ if !rate_ok(state, ip, "browse", RL_BROWSE.0, RL_BROWSE.1) {
+ return None;
}
- bucket.push(now);
- true
+ Some(lobbies_snapshot(state))
}
@@ -305,31 +389,6 @@ pub fn drop_lobby(state: &mut MasterState, session_id: &str, lobby_id: &str) {
state.lobby_by_public.remove(lobby_id);
}
-pub fn evict_if_full(state: &mut MasterState) {
- while state.lobbies.len() >= MAX_LOBBIES_GLOBAL {
- // drop the stalest last_seen
- let stalest = state
- .lobbies
- .values()
- .min_by_key(|lo| lo.last_seen)
- .map(|lo| (lo.session_id.clone(), lo.lobby_id.clone()));
- match stalest {
- Some((sid, pub_id)) => {
- drop_lobby(state, &sid, &pub_id);
- log(&format!("evicted stalest lobby {pub_id} (global cap)"));
- }
- None => break,
- }
- }
-}
-
-pub fn lobbies_per_ip(state: &MasterState, ip: &str) -> usize {
- // Count by the coarse bucket (audit M3), not the exact address, so an IPv6 /64
- // can't mint MAX_LOBBIES_PER_IP lobbies per /128.
- let bucket = ip_bucket(ip);
- state.lobbies.values().filter(|lo| ip_bucket(&lo.ip) == bucket).count()
-}
-
pub fn auth_by_session<'a>(state: &'a MasterState, body: &Value) -> Option<&'a Lobby> {
let sid = as_str(body, "sessionId")?;
let tok = as_str(body, "token")?;
@@ -343,105 +402,6 @@ pub fn auth_by_session<'a>(state: &'a MasterState, body: &Value) -> Option<&'a L
// ---- endpoint handlers : return (status, body) ------------------------------
-pub fn h_host(state: &mut MasterState, ip: &str, body: &Value) -> (u16, Value) {
- if !rate_ok(state, ip, "create", RL_CREATE.0, RL_CREATE.1) {
- return (429, json!({"error": "rate"}));
- }
- 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);
- lo.name = if name.is_empty() { "VOTV Coop".into() } else { name };
- // v122 Paper-pair identity: game + proto. `version` is the LEGACY pre-v122
- // display tag (old hosts sent their game version here) -- pass-through, no
- // fabricated default.
- lo.version = clamp_field(body, "version", MAX_VERSION);
- lo.game = clamp_field(body, "game", MAX_GAME);
- lo.proto = as_int(body, "proto", 0).clamp(0, 65535);
- // Version-pair gate (2026-08-29): shape-invalid game / impossible build
- // numbers are refused BEFORE the lobby exists -- see version_gate.rs.
- if let Err(reason) = version_gate_env(&lo.game, lo.proto) {
- log(&format!("host REFUSED from {}: {} (name='{}')", ip, reason, lo.name));
- return (400, json!({"error": reason}));
- }
- // The identity joiners dial: the host's own durable public key, `gen:<64 hex>`.
- // REQUIRED since 2026-08-29. It used to be optional, with a minted `h<16hex>`
- // fallback for an already-released host that sent none -- that fallback is
- // retired with the b<=133 cohort (user decision), and its retirement is not
- // cosmetic: a minted name is one the master ASSERTS on a host's behalf, and
- // the signaling relay now registers nothing that its holder did not sign for.
- // Advertising a name no peer can be registered under would list a lobby that
- // is unjoinable, which is the exact failure this endpoint exists to prevent.
- //
- // A pre-b145 host therefore gets a 400 here and never reaches the relay, so
- // its deprecation is a NAMED refusal at the first call rather than a silent
- // rendezvous failure two hops later.
- match as_str(body, "identity") {
- Some(id) if identity_shape_ok(id) => lo.host_identity = id.to_string(),
- _ => {
- log(&format!("host REFUSED from {}: missing/bad identity", ip));
- return (400, json!({"error": "this build is too old to host -- update Multivoid"}));
- }
- }
- lo.world = clamp_field(body, "world", MAX_WORLD);
- lo.locked = as_bool(body, "locked", false);
- lo.players_max = as_int(body, "players_max", 4).clamp(1, 4);
- (lo.build_sha, lo.build_key, lo.build_sig) = build_claim_from_body(body);
- lo.listed = host_listed(body);
-
- if as_str(body, "conn") == Some("direct") {
- let dp = as_int(body, "direct_port", -1);
- if !(1024..=65535).contains(&dp) {
- // REJECT loudly BEFORE registering (audit R1): a silent p2p downgrade
- // would hand joiners ICE creds a LanDirect host can't speak.
- return (400, json!({"error": "direct_port out of range (1024-65535)"}));
- }
- lo.conn = "direct".into();
- lo.direct_port = dp;
- }
- lo.players_cur = 1;
- lo.last_seen = Instant::now();
-
- let session_id = lo.session_id.clone();
- let lobby_id = lo.lobby_id.clone();
- let host_identity = lo.host_identity.clone();
- let token = lo.token.clone();
- let conn = lo.conn.clone();
- let is_direct = conn == "direct";
-
- state.lobby_by_public.insert(lobby_id.clone(), session_id.clone());
- state.lobbies.insert(session_id.clone(), lo);
- log(&format!(
- "host {} '{}' game={} b{} from {} ({} live)",
- lobby_id,
- state.lobbies[&session_id].name,
- state.lobbies[&session_id].game,
- state.lobbies[&session_id].proto,
- ip,
- state.lobbies.len()
- ));
-
- let mut resp = serde_json::Map::new();
- resp.insert("sessionId".into(), json!(session_id));
- resp.insert("lobbyId".into(), json!(lobby_id));
- resp.insert("hostIdentity".into(), json!(host_identity));
- resp.insert("token".into(), json!(token));
- resp.insert("conn".into(), json!(conn));
- // A DIRECT host never touches signaling/ICE -> no creds. P2P hosts get the block.
- // TURN cred is bound to the client's IP bucket (audit M2), not the host identity.
- if !is_direct {
- let block = ice_block(&ip_bucket(ip), TURN_TTL_HOST);
- if let (Some(turn), Some(lo)) = (block.get("turn"), state.lobbies.get_mut(&session_id)) {
- lo.turn = HostTurn::minted_now(turn, Instant::now());
- }
- resp.extend(block);
- }
- (200, Value::Object(resp))
-}
-
pub fn h_heartbeat(state: &mut MasterState, ip: &str, body: &Value) -> (u16, Value) {
if !rate_ok(state, ip, "mutate", RL_MUTATE.0, RL_MUTATE.1) {
return (429, json!({"error": "rate"}));
@@ -599,9 +559,12 @@ pub fn build_rows(state: &MasterState) -> Vec {
}
rows.push(json!({
"lobbyId": lo.lobby_id,
+ "identity": lo.host_identity, // the host's key; `proved` says it signed for it
+ "proved": lo.proved,
"name": lo.name,
"version": lo.version,
"game": lo.game, // v122: the host's VOTV game target (gate tier 1)
+ "game_build": lo.game_build, // b222: the game build the host runs, compared byte for byte
"proto": lo.proto,
"world": lo.world,
"locked": lo.locked,
@@ -619,11 +582,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,
@@ -633,26 +595,16 @@ 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)> {
let mut state = lock_state();
let r = match path {
- "/v1/host" => h_host(&mut state, ip, body),
+ "/v1/host" => h_host(&mut state, &HostCtx::live(), ip, body),
+ "/v1/host/challenge" => h_challenge(&mut state, Instant::now(), ip),
"/v1/heartbeat" => h_heartbeat(&mut state, ip, body),
"/v1/leave" => h_leave(&mut state, ip, body),
"/v1/visibility" => h_visibility(&mut state, ip, body),
@@ -663,7 +615,7 @@ pub fn dispatch_post(path: &str, ip: &str, body: &Value) -> Option<(u16, Value)>
}
pub async fn sweeper() {
- let max_window = [RL_CREATE.0, RL_JOIN.0, RL_MUTATE.0].into_iter().max().unwrap();
+ let max_window = [RL_CHALLENGE.0, RL_CREATE.0, RL_JOIN.0, RL_MUTATE.0, RL_BROWSE.0].into_iter().max().unwrap();
loop {
tokio::time::sleep(Duration::from_secs(30)).await;
let now = Instant::now();
@@ -679,108 +631,14 @@ pub async fn sweeper() {
drop_lobby(&mut state, &sid, &pub_id);
log(&format!("expired {pub_id} (stale {stale}s)"));
}
- // prune fully-stale rate buckets (else `rate` leaks one entry per distinct IP)
- state
- .rate
- .retain(|_, v| v.last().map(|last| now.duration_since(*last) < max_window).unwrap_or(false));
+ // prune fully-stale rate keys (else `rate` holds one entry per distinct IP until the cap)
+ state.rate.prune(now, max_window);
+ let forgotten = state.rate.take_forgotten();
+ if forgotten > 0 {
+ log(&format!("rate table at its cap: forgot {forgotten} least-recently-seen keys to admit new ones"));
+ }
}
}
#[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/host.rs b/server/src/lobby/host.rs
new file mode 100644
index 000000000..c251522e7
--- /dev/null
+++ b/server/src/lobby/host.rs
@@ -0,0 +1,225 @@
+//! The create domain: `/v1/host/challenge` and `/v1/host`, the handlers that list a lobby, and the
+//! row-table helpers they alone use (replace by key, the per-address count, the eviction).
+
+use super::{
+ as_bool, as_int, as_str, build_claim_from_body, clamp_field, drop_lobby, host_listed, rate_ok, Lobby,
+ MasterState, MAX_GAME, MAX_GAME_BUILD, MAX_LOBBIES_GLOBAL, MAX_LOBBIES_PER_IP, MAX_NAME, MAX_VERSION,
+ MAX_WORLD, RL_CHALLENGE, RL_CREATE,
+};
+use crate::common::{identity_shape_ok, ip_bucket, log};
+use crate::ice::{ice_block, HostTurn, TURN_TTL_HOST};
+use crate::lobby_proof::judge;
+use crate::master_config::CFG;
+use crate::version_gate::{below_floor, version_gate_env};
+use serde_json::{json, Value};
+use std::time::Instant;
+
+/// Make room for one more row: while the table is full, drop the stalest UNPROVED row, and only
+/// when every row is proved the stalest proved one. A proved create is never refused at the cap;
+/// an unproved one is, before this runs, when every row is proved. MTA caps nothing: each client
+/// queries the port of every row it lists itself (Client/core/ServerBrowser/CServerList.cpp:490-595),
+/// so a bogus row costs a viewer a probe and the master nothing (its own side is not in the
+/// reference tree); a master that takes pushes needs a cap, and the proof ranks who leaves first.
+pub fn evict_if_full(state: &mut MasterState) {
+ while state.lobbies.len() >= MAX_LOBBIES_GLOBAL {
+ let victim = state
+ .lobbies
+ .values()
+ .min_by_key(|lo| (lo.proved, lo.last_seen))
+ .map(|lo| (lo.session_id.clone(), lo.lobby_id.clone(), lo.proved));
+ match victim {
+ Some((sid, pub_id, proved)) => {
+ drop_lobby(state, &sid, &pub_id);
+ let tier = if proved { "proved" } else { "unproved" };
+ log(&format!("evicted stalest {tier} lobby {pub_id} (global cap)"));
+ }
+ None => break,
+ }
+ }
+}
+
+/// The (session, lobby) ids of every row that lists `identity`.
+fn rows_of_key(state: &MasterState, identity: &str) -> Vec<(String, String)> {
+ let rows = state.lobbies.values().filter(|lo| lo.host_identity == identity);
+ rows.map(|lo| (lo.session_id.clone(), lo.lobby_id.clone())).collect()
+}
+
+pub fn lobbies_per_ip(state: &MasterState, ip: &str) -> usize {
+ // Count by the coarse bucket (audit M3), not the exact address, so an IPv6 /64
+ // can't mint MAX_LOBBIES_PER_IP lobbies per /128.
+ let bucket = ip_bucket(ip);
+ state.lobbies.values().filter(|lo| ip_bucket(&lo.ip) == bucket).count()
+}
+
+/// `POST /v1/host/challenge`: the nonce a create signs. Counted in its own rate class, bound to
+/// the caller's address bucket, single use, alive 30 s. Issuing stores nothing (see
+/// `lobby_proof::Nonces`), so no flood of challenges can fill a table; the rate class cannot
+/// refuse an honest host either (`rate_table`). MTA's announce has no challenge
+/// (CMasterServerAnnouncer.h:235-267) and its server-side one is answered, not issued
+/// (CHTTPD.cpp:131-170): here the master issues it because it is the party that must be convinced.
+pub fn h_challenge(state: &mut MasterState, now: Instant, ip: &str) -> (u16, Value) {
+ if !rate_ok(state, ip, "challenge", RL_CHALLENGE.0, RL_CHALLENGE.1) {
+ return (429, json!({"error": "rate"}));
+ }
+ (200, json!({"nonce": state.nonces.issue(&ip_bucket(ip), now)}))
+}
+
+/// What `h_host` reads from outside the request: the clock, this master's names, the floor, the block list.
+pub struct HostCtx<'a> {
+ pub now: Instant,
+ pub names: &'a [String],
+ pub min_listed_proto: i64,
+ /// The operator's block list (`COOP_BLOCKED_IDENTITIES`): identities no create may list.
+ pub blocked: &'a [String],
+}
+
+impl HostCtx<'static> {
+ pub fn live() -> HostCtx<'static> {
+ HostCtx {
+ now: Instant::now(),
+ names: &CFG.public_names,
+ min_listed_proto: CFG.min_listed_proto,
+ blocked: &CFG.blocked_identities,
+ }
+ }
+}
+
+/// `POST /v1/host`. Refusals run in this order: shape, block list, nonce, proof, floor, caps, then the eviction.
+pub fn h_host(state: &mut MasterState, ctx: &HostCtx, ip: &str, body: &Value) -> (u16, Value) {
+ if !rate_ok(state, ip, "create", RL_CREATE.0, RL_CREATE.1) {
+ return (429, json!({"error": "rate"}));
+ }
+
+ let mut lo = Lobby::draft(ip);
+ let name = clamp_field(body, "name", MAX_NAME);
+ lo.name = if name.is_empty() { "VOTV Coop".into() } else { name };
+ // v122 Paper-pair identity: game + proto. `version` is the LEGACY pre-v122
+ // display tag (old hosts sent their game version here) -- pass-through, no
+ // fabricated default.
+ lo.version = clamp_field(body, "version", MAX_VERSION);
+ lo.game = clamp_field(body, "game", MAX_GAME);
+ lo.game_build = clamp_field(body, "game_build", MAX_GAME_BUILD);
+ lo.proto = as_int(body, "proto", 0).clamp(0, 65535);
+ // Version-pair gate (2026-08-29): shape-invalid game / impossible build
+ // numbers are refused BEFORE the lobby exists -- see version_gate.rs.
+ if let Err(reason) = version_gate_env(&lo.game, lo.proto, &lo.game_build) {
+ log(&format!("host REFUSED from {}: {} (name='{}')", ip, reason, lo.name));
+ return (400, json!({"error": reason}));
+ }
+ // The identity joiners dial: the host's own durable public key, `gen:<64 hex>`.
+ // REQUIRED since 2026-08-29. It used to be optional, with a minted `h<16hex>`
+ // fallback for an already-released host that sent none -- that fallback is
+ // retired with the b<=133 cohort (user decision), and its retirement is not
+ // cosmetic: a minted name is one the master ASSERTS on a host's behalf, and
+ // the signaling relay now registers nothing that its holder did not sign for.
+ // Advertising a name no peer can be registered under would list a lobby that
+ // is unjoinable, which is the exact failure this endpoint exists to prevent.
+ //
+ // A pre-b145 host therefore gets a 400 here and never reaches the relay, so
+ // its deprecation is a NAMED refusal at the first call rather than a silent
+ // rendezvous failure two hops later.
+ match as_str(body, "identity") {
+ Some(id) if identity_shape_ok(id) => lo.host_identity = id.to_string(),
+ _ => {
+ log(&format!("host REFUSED from {}: missing/bad identity", ip));
+ return (400, json!({"error": "this build is too old to host -- update Multivoid"}));
+ }
+ }
+ lo.world = clamp_field(body, "world", MAX_WORLD);
+ lo.locked = as_bool(body, "locked", false);
+ lo.players_max = as_int(body, "players_max", 4).clamp(1, 4);
+ (lo.build_sha, lo.build_key, lo.build_sig) = build_claim_from_body(body);
+ lo.listed = host_listed(body);
+
+ if as_str(body, "conn") == Some("direct") {
+ let dp = as_int(body, "direct_port", -1);
+ if !(1024..=65535).contains(&dp) {
+ // REJECT loudly BEFORE registering (audit R1): a silent p2p downgrade
+ // would hand joiners ICE creds a LanDirect host can't speak.
+ return (400, json!({"error": "direct_port out of range (1024-65535)"}));
+ }
+ lo.conn = "direct".into();
+ lo.direct_port = dp;
+ }
+ lo.players_cur = 1;
+ lo.last_seen = ctx.now;
+
+ // The proof. A nonce is spent here, so every shape refusal above leaves it alive.
+ match judge(&mut state.nonces, ctx.names, &ip_bucket(ip), ctx.now, body, &lo.host_identity, lo.proto) {
+ Ok(proved) => lo.proved = proved,
+ Err(why) => {
+ let (status, error) = why.answer();
+ log(&format!("host REFUSED from {ip}: {error} (b{})", lo.proto));
+ return (status, json!({"error": error}));
+ }
+ }
+ if below_floor(lo.proto, ctx.min_listed_proto) {
+ log(&format!("host REFUSED from {ip}: b{} is below the listed floor {}", lo.proto, ctx.min_listed_proto));
+ return (403, json!({"error": "build-too-old"}));
+ }
+ // The operator's block (MTA's master flags a row RESTRICTION_BLOCK_SERVER and every client hides
+ // it, Client/core/ServerBrowser/CServerList.h:75 and CServerBrowser.cpp:1358). Asked after the
+ // proof and the floor: a build that must prove learns its key is blocked only by proving it, so
+ // an unauthenticated create cannot probe the list. A create of an older build, which need not
+ // prove, proves nothing and so may learn it; it is refused here all the same. A proved blocked
+ // create has spent its nonce, as any proved create does.
+ if ctx.blocked.contains(&lo.host_identity) {
+ log(&format!("host REFUSED from {ip}: blocked identity (b{})", lo.proto));
+ return (403, json!({"error": "blocked"}));
+ }
+
+ // One row per proved key: the caps count the rows listing it as gone; nothing drops until they pass.
+ // MTA's identity of a row is address and port (CServerList.cpp:700-706); a P2P host has no stable
+ // address a joiner could dial, so its key is the identity and a restarted host keeps one row.
+ let replaced = if lo.proved { rows_of_key(state, &lo.host_identity) } else { Vec::new() };
+ let mine = |sid: &String| state.lobbies.get(sid).is_some_and(|row| ip_bucket(&row.ip) == ip_bucket(ip));
+ if lobbies_per_ip(state, ip) - replaced.iter().filter(|(sid, _)| mine(sid)).count() >= MAX_LOBBIES_PER_IP {
+ return (429, json!({"error": "too many lobbies for this address"}));
+ }
+ // A table of nothing but proved rows is not made room in by an unproved create: that would
+ // let unproved junk churn real hosts out. A proved create always gets a place.
+ if !lo.proved && state.lobbies.len() >= MAX_LOBBIES_GLOBAL && state.lobbies.values().all(|row| row.proved) {
+ log(&format!("host REFUSED from {ip}: b{} unproved, every row is proved and the table is full", lo.proto));
+ return (429, json!({"error": "the master is full of proved lobbies"}));
+ }
+ lo.draw_ids();
+ if let Some((_, public_id)) = replaced.first() {
+ lo.lobby_id = public_id.clone(); // the row keeps its public id; session and token are re-issued
+ }
+ for (sid, public_id) in &replaced {
+ drop_lobby(state, sid, public_id);
+ }
+
+ let session_id = lo.session_id.clone();
+ let lobby_id = lo.lobby_id.clone();
+ let host_identity = lo.host_identity.clone();
+ let token = lo.token.clone();
+ 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);
+ let said = format!("host {lobby_id} '{}' game={} ({}) b{}", lo.name, lo.game, lo.game_build, lo.proto);
+ let said = format!("{said} {} from {ip}", if lo.proved { "proved" } else { "unproved" });
+ state.lobby_by_public.insert(lobby_id.clone(), session_id.clone());
+ state.lobbies.insert(session_id.clone(), lo);
+ log(&format!("{said} ({} live)", state.lobbies.len()));
+
+ let mut resp = serde_json::Map::new();
+ resp.insert("sessionId".into(), json!(session_id));
+ resp.insert("lobbyId".into(), json!(lobby_id));
+ resp.insert("hostIdentity".into(), json!(host_identity));
+ resp.insert("token".into(), json!(token));
+ resp.insert("conn".into(), json!(conn));
+ // A DIRECT host never touches signaling/ICE -> no creds. P2P hosts get the block.
+ // TURN cred is bound to the client's IP bucket (audit M2), not the host identity.
+ if !is_direct {
+ let block = ice_block(&ip_bucket(ip), TURN_TTL_HOST);
+ if let (Some(turn), Some(lo)) = (block.get("turn"), state.lobbies.get_mut(&session_id)) {
+ lo.turn = HostTurn::minted_now(turn, Instant::now());
+ }
+ resp.extend(block);
+ }
+ (200, Value::Object(resp))
+}
diff --git a/server/src/lobby/tests.rs b/server/src/lobby/tests.rs
new file mode 100644
index 000000000..7cb7a1ba8
--- /dev/null
+++ b/server/src/lobby/tests.rs
@@ -0,0 +1,225 @@
+//! The lobby domain's unit tests. The announce proof through `h_host` is in `tests/proof.rs`.
+
+mod proof;
+
+use super::host::{h_challenge, h_host, HostCtx};
+use super::{
+ build_claim_from_body, h_browse, host_listed, latest_answer, parse_latest_games, rate_ok, LatestGame, Lobby,
+ LobbyLinks, MasterState, LATEST_URL, MAX_LOBBIES_GLOBAL, MAX_RATE_KEYS, RL_BROWSE,
+};
+use crate::common::{build_claim_shape_ok, identity_shape_ok};
+use crate::lobby_proof::{MAX_SPENT, SPENT_ENTRY_BYTES};
+
+#[test]
+fn latest_games_parse_keeps_good_entries_and_names_bad_ones() {
+ let (games, refused) =
+ parse_latest_games("0.9.0n:221:0.9.0n b221, 0.10.0:230:0.10.0 b230,garbage,0.9.0n:0:x,0.9.0n:5:a:b");
+ assert_eq!(games.len(), 2);
+ assert_eq!(games[0], LatestGame { game: "0.9.0n".into(), proto: 221, tag: "0.9.0n b221".into() });
+ assert_eq!(games[1].game, "0.10.0");
+ assert_eq!(refused, vec!["garbage".to_string(), "0.9.0n:0:x".to_string(), "0.9.0n:5:a:b".to_string()]);
+ let (dup, refused_dup) = parse_latest_games("0.9.0n:221:a,0.9.0n:222:b");
+ assert_eq!(dup.len(), 1, "a game named twice keeps its first entry");
+ assert_eq!(refused_dup, vec!["0.9.0n:222:b".to_string()]);
+ let (none, refused_none) = parse_latest_games("");
+ assert!(none.is_empty() && refused_none.is_empty());
+}
+
+#[test]
+fn latest_answer_gives_the_old_cohort_its_own_game() {
+ let (games, _) = parse_latest_games("0.9.0n:221:0.9.0n b221,0.10.0:230:0.10.0 b230");
+ let body = latest_answer(&games, LATEST_URL);
+ assert_eq!(body["proto"], 221, "the legacy fields are 0.9.0n's, not the newest release's");
+ assert_eq!(body["mod"], "0.9.0n b221");
+ assert_eq!(body["games"].as_array().map(|a| a.len()), Some(2));
+ let empty = latest_answer(&[], LATEST_URL);
+ assert_eq!(empty["proto"], 0);
+ assert!(empty.get("games").is_none(), "no list, no field");
+}
+
+#[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!({})));
+}
+
+#[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 ctx = HostCtx { now: std::time::Instant::now(), names: &[], min_listed_proto: 0, blocked: &[] };
+ let (status, _) = h_host(&mut state, &ctx, "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)));
+}
+
+#[test]
+fn a_challenge_answers_a_new_nonce_each_time_and_holds_no_table() {
+ let (mut state, now) = (MasterState::new(), std::time::Instant::now());
+ let mut nonces = std::collections::HashSet::new();
+ for n in 0..10 {
+ let (status, body) = h_challenge(&mut state, now, &format!("198.51.100.{n}"));
+ assert_eq!(status, 200);
+ nonces.insert(body["nonce"].as_str().expect("no nonce").to_string());
+ }
+ assert_eq!(nonces.len(), 10, "two challenges answered one nonce");
+ assert_eq!(state.nonces.spent_len(), 0, "a challenge left something in the master");
+}
+
+#[test]
+fn a_challenge_is_counted_in_its_own_rate_class() {
+ let (mut state, now) = (MasterState::new(), std::time::Instant::now());
+ for _ in 0..10 {
+ assert_eq!(h_challenge(&mut state, now, "198.51.100.9").0, 200);
+ }
+ let (status, body) = h_challenge(&mut state, now, "198.51.100.9");
+ assert_eq!((status, body["error"].as_str()), (429, Some("rate")));
+ // The create class is untouched: a create from the same address is judged, not rate-limited.
+ let ctx = HostCtx { now, names: &[], min_listed_proto: 0, blocked: &[] };
+ assert_eq!(h_host(&mut state, &ctx, "198.51.100.9", &serde_json::json!({})).0, 400);
+}
+
+/// Every key of the table taken by a different address, in the challenge class.
+fn flood_the_rate_table(state: &mut MasterState) {
+ for i in 0..MAX_RATE_KEYS {
+ let ip = format!("10.{}.{}.{}", (i >> 16) & 255, (i >> 8) & 255, i & 255);
+ assert!(rate_ok(state, &ip, "challenge", super::RL_CHALLENGE.0, super::RL_CHALLENGE.1), "flood key {i}");
+ }
+ assert_eq!(state.rate.len(), MAX_RATE_KEYS);
+}
+
+#[test]
+fn a_full_rate_table_refuses_no_address_it_does_not_hold() {
+ let (mut state, now) = (MasterState::new(), std::time::Instant::now());
+ flood_the_rate_table(&mut state);
+ // An honest host from a new address: its challenge, its create and a joiner's join all pass the rate class.
+ let (status, body) = h_challenge(&mut state, now, "198.51.100.77");
+ assert_eq!((status, body["error"].as_str()), (200, None), "a flood of new addresses refused a challenge");
+ let ctx = HostCtx { now, names: &[], min_listed_proto: 0, blocked: &[] };
+ assert_eq!(h_host(&mut state, &ctx, "198.51.100.77", &serde_json::json!({})).0, 400, "the create hit the rate class");
+ assert_eq!(super::h_join(&mut state, "198.51.100.78", &serde_json::json!({})).0, 400, "the join hit the rate class");
+ assert_eq!(state.rate.len(), MAX_RATE_KEYS, "the table grew past its cap");
+}
+
+#[test]
+fn a_full_rate_table_forgets_the_oldest_flooder_and_keeps_the_newest() {
+ let (mut state, now) = (MasterState::new(), std::time::Instant::now());
+ flood_the_rate_table(&mut state);
+ assert!(h_challenge(&mut state, now, "198.51.100.77").0 == 200);
+ assert!(!state.rate.contains("10.0.0.0|challenge"), "the least recently seen key was kept");
+ assert!(state.rate.contains("198.51.100.77|challenge"));
+ assert_eq!(state.rate.take_forgotten(), 1);
+}
+
+#[test]
+fn the_list_has_its_own_rate_class() {
+ let mut state = MasterState::new();
+ for n in 0..RL_BROWSE.1 {
+ assert!(h_browse(&mut state, "198.51.100.5").is_some(), "read {n} was refused");
+ }
+ assert!(h_browse(&mut state, "198.51.100.5").is_none(), "the list was not rate limited");
+ assert!(h_browse(&mut state, "198.51.100.6").is_some(), "another address shared the budget");
+ // Reading the list spends no other class's budget.
+ assert_eq!(super::h_heartbeat(&mut state, "198.51.100.5", &serde_json::json!({})).0, 403);
+}
+
+#[test]
+fn the_spent_set_fits_the_masters_memory_budget() {
+ // 16 MiB of the unit's 128M: the set must never be the reason the master is killed.
+ assert!(MAX_SPENT * SPENT_ENTRY_BYTES <= 16 << 20, "MAX_SPENT outgrew its memory budget");
+}
diff --git a/server/src/lobby/tests/proof.rs b/server/src/lobby/tests/proof.rs
new file mode 100644
index 000000000..c50d92a5a
--- /dev/null
+++ b/server/src/lobby/tests/proof.rs
@@ -0,0 +1,428 @@
+//! `h_host` and the announce proof: a handler-level test over a constructed `MasterState`, with the
+//! master's clock, names, floor and block list injected through `HostCtx`.
+
+use crate::lobby::host::{h_challenge, h_host, HostCtx};
+use crate::lobby::{build_rows, h_heartbeat, Lobby, MasterState, MAX_LOBBIES_GLOBAL};
+use crate::lobby_proof::{blob, NONCE_LIFE};
+use crate::version_gate::MIN_PROVED_PROTO;
+use ring::rand::SystemRandom;
+use ring::signature::{Ed25519KeyPair, KeyPair};
+use serde_json::{json, Value};
+use std::time::{Duration, Instant};
+
+const NAME: &str = "master.multivoid.dev";
+const IP: &str = "198.51.100.9";
+const NEW: i64 = MIN_PROVED_PROTO;
+const OLD: i64 = MIN_PROVED_PROTO - 1;
+
+struct Host {
+ ident: String,
+ kp: Ed25519KeyPair,
+}
+
+fn host() -> Host {
+ let pkcs8 = Ed25519KeyPair::generate_pkcs8(&SystemRandom::new()).unwrap();
+ let kp = Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
+ let ident = format!("gen:{}", hex(kp.public_key().as_ref()));
+ Host { ident, kp }
+}
+
+fn hex(bytes: &[u8]) -> String {
+ bytes.iter().map(|b| format!("{b:02x}")).collect()
+}
+
+fn sign(h: &Host, nonce: &str, name: &str) -> String {
+ hex(h.kp.sign(&blob(&h.ident, nonce, name)).as_ref())
+}
+
+/// The master's side of the world: its clock, the names it answers to, its floor, its block list.
+struct Env {
+ now: Instant,
+ names: Vec,
+ floor: i64,
+ blocked: Vec,
+}
+
+impl Env {
+ fn new() -> Env {
+ Env { now: Instant::now(), names: vec![NAME.to_string()], floor: 0, blocked: Vec::new() }
+ }
+
+ fn create(&self, state: &mut MasterState, ip: &str, body: &Value) -> (u16, Value) {
+ let ctx = HostCtx { now: self.now, names: &self.names, min_listed_proto: self.floor, blocked: &self.blocked };
+ h_host(state, &ctx, ip, body)
+ }
+
+ fn nonce(&self, state: &mut MasterState, ip: &str) -> String {
+ let (status, answer) = h_challenge(state, self.now, ip);
+ assert_eq!(status, 200, "the challenge was refused: {answer}");
+ answer["nonce"].as_str().unwrap().to_string()
+ }
+
+ /// A create body for `h` carrying a fresh nonce for `ip`, signed for `name`.
+ fn signed(&self, state: &mut MasterState, ip: &str, h: &Host, proto: i64, name: &str) -> Value {
+ let nonce = self.nonce(state, ip);
+ let mut body = plain(h, proto);
+ body["proof"] = json!(sign(h, &nonce, name));
+ body["nonce"] = json!(nonce);
+ body
+ }
+}
+
+/// A create body with no nonce and no proof: what a build that cannot sign sends.
+fn plain(h: &Host, proto: i64) -> Value {
+ json!({"name": "t", "game": "0.9.0n", "proto": proto, "identity": h.ident, "conn": "direct", "direct_port": 7777})
+}
+
+fn error_of(r: &(u16, Value)) -> (u16, &str) {
+ (r.0, r.1["error"].as_str().unwrap_or(""))
+}
+
+fn rows_of<'a>(rows: &'a [Value], h: &Host) -> Vec<&'a Value> {
+ rows.iter().filter(|r| r["identity"] == h.ident).collect()
+}
+
+/// A table filled to the global cap, each row from its own address; row `i` is `i` milliseconds
+/// newer than `base`, and `proved(i)` says whether it is proved.
+fn full_table(proved: impl Fn(usize) -> bool, base: Instant) -> (MasterState, Vec) {
+ let mut state = MasterState::new();
+ let mut sessions = Vec::new();
+ for i in 0..MAX_LOBBIES_GLOBAL {
+ let mut lo = Lobby::new(&format!("10.{}.{}.1", i / 250, i % 250));
+ lo.host_identity = format!("gen:{i:064x}");
+ lo.proved = proved(i);
+ lo.last_seen = base + Duration::from_millis(i as u64);
+ sessions.push(lo.session_id.clone());
+ state.lobby_by_public.insert(lo.lobby_id.clone(), lo.session_id.clone());
+ state.lobbies.insert(lo.session_id.clone(), lo);
+ }
+ (state, sessions)
+}
+
+#[test]
+fn a_valid_proof_lists_a_proved_row_that_names_its_key() {
+ let (env, mut state, h) = (Env::new(), MasterState::new(), host());
+ let body = env.signed(&mut state, IP, &h, NEW, NAME);
+ let (status, answer) = env.create(&mut state, IP, &body);
+ assert_eq!(status, 200, "{answer}");
+ let rows = build_rows(&state);
+ let mine = rows_of(&rows, &h);
+ assert_eq!(mine.len(), 1);
+ assert_eq!(mine[0]["proved"], true);
+ assert_eq!(mine[0]["lobbyId"], answer["lobbyId"]);
+}
+
+#[test]
+fn a_forged_proof_is_refused_and_lists_nothing() {
+ let (env, mut state, victim, attacker) = (Env::new(), MasterState::new(), host(), host());
+ // The attacker signs the blob that names the VICTIM's key, with its own.
+ let nonce = env.nonce(&mut state, IP);
+ let mut body = plain(&victim, NEW);
+ body["nonce"] = json!(nonce);
+ body["proof"] = json!(hex(attacker.kp.sign(&blob(&victim.ident, &nonce, NAME)).as_ref()));
+ assert_eq!(error_of(&env.create(&mut state, IP, &body)), (400, "bad-proof"));
+ assert!(state.lobbies.is_empty(), "a forged proof listed a row");
+}
+
+#[test]
+fn a_proof_that_is_present_must_verify_whatever_the_build() {
+ let (env, mut state, h, other) = (Env::new(), MasterState::new(), host(), host());
+ let nonce = env.nonce(&mut state, IP);
+ let mut body = plain(&h, OLD);
+ body["nonce"] = json!(nonce);
+ body["proof"] = json!(sign(&other, &nonce, NAME));
+ assert_eq!(error_of(&env.create(&mut state, IP, &body)), (400, "bad-proof"));
+ assert!(state.lobbies.is_empty());
+}
+
+#[test]
+fn a_malformed_proof_or_nonce_is_bad_proof_before_any_nonce_is_spent() {
+ let (env, mut state, h) = (Env::new(), MasterState::new(), host());
+ let nonce = env.nonce(&mut state, IP);
+ let good = sign(&h, &nonce, NAME);
+ for (n, p) in [(nonce.as_str(), &good[..126]), (nonce.as_str(), ""), (&nonce[..60], good.as_str()), ("", good.as_str())] {
+ let mut body = plain(&h, NEW);
+ body["nonce"] = json!(n);
+ body["proof"] = json!(p);
+ assert_eq!(error_of(&env.create(&mut state, IP, &body)), (400, "bad-proof"), "nonce {n:?}");
+ }
+ let mut body = plain(&h, NEW);
+ body["nonce"] = json!(nonce);
+ body["proof"] = json!(7);
+ assert_eq!(error_of(&env.create(&mut state, IP, &body)), (400, "bad-proof"));
+ // None of those spent the nonce: the honest create with it still goes through.
+ body["proof"] = json!(good);
+ assert_eq!(env.create(&mut state, IP, &body).0, 200);
+}
+
+#[test]
+fn a_replayed_nonce_is_expired() {
+ let (env, mut state, h) = (Env::new(), MasterState::new(), host());
+ let body = env.signed(&mut state, IP, &h, NEW, NAME);
+ assert_eq!(env.create(&mut state, IP, &body).0, 200);
+ assert_eq!(error_of(&env.create(&mut state, IP, &body)), (410, "nonce-expired"));
+}
+
+#[test]
+fn another_buckets_nonce_is_expired_and_its_owner_keeps_it() {
+ let (env, mut state, h) = (Env::new(), MasterState::new(), host());
+ let body = env.signed(&mut state, IP, &h, NEW, NAME);
+ assert_eq!(error_of(&env.create(&mut state, "198.51.100.77", &body)), (410, "nonce-expired"));
+ assert!(state.lobbies.is_empty());
+ assert_eq!(env.create(&mut state, IP, &body).0, 200, "the owner lost its nonce to a stranger");
+}
+
+#[test]
+fn a_nonce_older_than_thirty_seconds_is_expired() {
+ let (mut env, mut state, h) = (Env::new(), MasterState::new(), host());
+ let body = env.signed(&mut state, IP, &h, NEW, NAME);
+ // The expiry is whole seconds, so a nonce lives a little over thirty: two more clears it.
+ env.now += NONCE_LIFE + Duration::from_secs(2);
+ assert_eq!(error_of(&env.create(&mut state, IP, &body)), (410, "nonce-expired"));
+}
+
+#[test]
+fn a_nonce_still_answers_at_thirty_seconds() {
+ let (mut env, mut state, h) = (Env::new(), MasterState::new(), host());
+ let body = env.signed(&mut state, IP, &h, NEW, NAME);
+ env.now += NONCE_LIFE;
+ assert_eq!(env.create(&mut state, IP, &body).0, 200, "a nonce died before its thirty seconds");
+}
+
+#[test]
+fn a_nonce_with_a_flipped_digit_is_expired_even_with_a_valid_proof_over_it() {
+ let (env, mut state, h) = (Env::new(), MasterState::new(), host());
+ let nonce = env.nonce(&mut state, IP);
+ for at in [0, 20, 63] {
+ let mut digits = nonce.clone().into_bytes();
+ digits[at] = if digits[at] == b'0' { b'1' } else { b'0' };
+ let forged = String::from_utf8(digits).unwrap();
+ let mut body = plain(&h, NEW);
+ body["proof"] = json!(sign(&h, &forged, NAME)); // the holder signs the forged nonce itself
+ body["nonce"] = json!(forged);
+ assert_eq!(error_of(&env.create(&mut state, IP, &body)), (410, "nonce-expired"), "digit {at}");
+ }
+ assert!(state.lobbies.is_empty());
+}
+
+#[test]
+fn a_nonce_from_another_master_is_expired() {
+ let (env, mut one, mut two, h) = (Env::new(), MasterState::new(), MasterState::new(), host());
+ let body = env.signed(&mut one, IP, &h, NEW, NAME);
+ assert_eq!(error_of(&env.create(&mut two, IP, &body)), (410, "nonce-expired"), "two secrets, one nonce");
+ assert_eq!(env.create(&mut one, IP, &body).0, 200, "the issuing master lost its own nonce");
+}
+
+#[test]
+fn a_flood_of_challenges_refuses_no_honest_create() {
+ let (env, mut state, h) = (Env::new(), MasterState::new(), host());
+ for n in 0..2000 {
+ let ip = format!("203.0.{}.{}", n / 250, n % 250);
+ assert_eq!(h_challenge(&mut state, env.now, &ip).0, 200);
+ }
+ let body = env.signed(&mut state, IP, &h, NEW, NAME);
+ assert_eq!(env.create(&mut state, IP, &body).0, 200);
+}
+
+#[test]
+fn an_unproved_create_at_a_full_table_of_proved_rows_is_refused_and_evicts_nobody() {
+ let (env, h) = (Env::new(), host());
+ let (mut state, sessions) = full_table(|_| true, env.now);
+ let refused = env.create(&mut state, IP, &plain(&h, OLD));
+ assert_eq!(refused.0, 429, "{}", refused.1);
+ assert_eq!(state.lobbies.len(), MAX_LOBBIES_GLOBAL);
+ assert!(sessions.iter().all(|s| state.lobbies.contains_key(s)), "an unproved create evicted a proved row");
+ // The refusal costs a proved host nothing: its create still takes the stalest proved row's place.
+ let body = env.signed(&mut state, IP, &h, NEW, NAME);
+ assert_eq!(env.create(&mut state, IP, &body).0, 200);
+ assert!(!state.lobbies.contains_key(&sessions[0]));
+}
+
+#[test]
+fn a_proof_signed_for_another_master_is_refused_and_a_configured_alias_is_accepted() {
+ let (mut env, mut state, h) = (Env::new(), MasterState::new(), host());
+ let rogue = env.signed(&mut state, IP, &h, NEW, "rogue.example");
+ assert_eq!(error_of(&env.create(&mut state, IP, &rogue)), (400, "bad-proof"));
+ let alias = env.signed(&mut state, IP, &h, NEW, "203.0.113.7");
+ assert_eq!(error_of(&env.create(&mut state, IP, &alias)), (400, "bad-proof"), "an unlisted name was accepted");
+ env.names.push("203.0.113.7".to_string());
+ let alias = env.signed(&mut state, IP, &h, NEW, "203.0.113.7");
+ assert_eq!(env.create(&mut state, IP, &alias).0, 200, "a configured alias was refused");
+}
+
+#[test]
+fn a_build_that_signs_is_refused_without_a_proof_and_an_older_one_is_listed_unproved() {
+ let (env, mut state, new_host, old_host) = (Env::new(), MasterState::new(), host(), host());
+ assert_eq!(error_of(&env.create(&mut state, IP, &plain(&new_host, NEW))), (409, "proof-required"));
+ assert_eq!(error_of(&env.create(&mut state, IP, &plain(&new_host, 65535))), (409, "proof-required"));
+ assert!(state.lobbies.is_empty());
+ assert_eq!(env.create(&mut state, IP, &plain(&old_host, OLD)).0, 200);
+ let rows = build_rows(&state);
+ let mine = rows_of(&rows, &old_host);
+ assert_eq!((mine.len(), &mine[0]["proved"]), (1, &json!(false)));
+}
+
+#[test]
+fn a_build_below_the_floor_is_refused_after_its_proof_is_judged() {
+ let (mut env, mut state, h) = (Env::new(), MasterState::new(), host());
+ env.floor = NEW;
+ assert_eq!(error_of(&env.create(&mut state, IP, &plain(&h, OLD))), (403, "build-too-old"));
+ // A bad proof is judged first, so it is a 400 even below the floor.
+ let nonce = env.nonce(&mut state, IP);
+ let mut body = plain(&h, OLD);
+ body["nonce"] = json!(nonce);
+ body["proof"] = json!("00".repeat(64));
+ assert_eq!(error_of(&env.create(&mut state, IP, &body)), (400, "bad-proof"));
+ let at_floor = env.signed(&mut state, IP, &h, NEW, NAME);
+ assert_eq!(env.create(&mut state, IP, &at_floor).0, 200);
+ assert_eq!(state.lobbies.len(), 1);
+}
+
+#[test]
+fn a_full_table_evicts_the_stalest_unproved_row_before_any_proved_one() {
+ let (env, h) = (Env::new(), host());
+ // Rows 0..500 are proved and the OLDEST; rows 500..1000 are unproved and newer.
+ let (mut state, sessions) = full_table(|i| i < 500, env.now);
+ let body = env.signed(&mut state, IP, &h, NEW, NAME);
+ assert_eq!(env.create(&mut state, IP, &body).0, 200, "a proved create was refused at the cap");
+ assert_eq!(state.lobbies.len(), MAX_LOBBIES_GLOBAL);
+ assert!(!state.lobbies.contains_key(&sessions[500]), "the stalest unproved row survived");
+ assert!(sessions[..500].iter().all(|s| state.lobbies.contains_key(s)), "a proved row was evicted");
+ assert!(sessions[501..].iter().all(|s| state.lobbies.contains_key(s)), "a newer unproved row was evicted");
+}
+
+#[test]
+fn a_full_table_of_proved_rows_evicts_the_stalest_proved_and_still_lists_the_create() {
+ let (env, h) = (Env::new(), host());
+ let (mut state, sessions) = full_table(|_| true, env.now);
+ let body = env.signed(&mut state, IP, &h, NEW, NAME);
+ assert_eq!(env.create(&mut state, IP, &body).0, 200);
+ assert_eq!(state.lobbies.len(), MAX_LOBBIES_GLOBAL);
+ assert!(!state.lobbies.contains_key(&sessions[0]), "the stalest proved row survived");
+ assert!(sessions[1..].iter().all(|s| state.lobbies.contains_key(s)));
+}
+
+#[test]
+fn a_flood_of_unproved_creates_never_displaces_a_proved_row() {
+ let mut env = Env::new();
+ let (mut state, sessions) = full_table(|i| i == 0, env.now); // one proved row, the stalest
+ env.now += Duration::from_secs(2);
+ for n in 0..40 {
+ let ip = format!("203.0.113.{n}");
+ assert_eq!(env.create(&mut state, &ip, &plain(&host(), OLD)).0, 200);
+ }
+ assert!(state.lobbies.contains_key(&sessions[0]), "40 unproved creates evicted the proved row");
+ assert_eq!(state.lobbies.len(), MAX_LOBBIES_GLOBAL);
+}
+
+#[test]
+fn a_proved_create_replaces_the_row_of_its_key_in_place() {
+ let (env, mut state, h) = (Env::new(), MasterState::new(), host());
+ let first = env.signed(&mut state, IP, &h, NEW, NAME);
+ let (_, a) = env.create(&mut state, IP, &first);
+ let second = env.signed(&mut state, IP, &h, NEW, NAME);
+ let (status, b) = env.create(&mut state, IP, &second);
+ assert_eq!(status, 200);
+ assert_eq!(state.lobbies.len(), 1, "a restart added a row");
+ assert_eq!(a["lobbyId"], b["lobbyId"], "the row lost its public id");
+ assert_ne!(a["sessionId"], b["sessionId"]);
+ assert_ne!(a["token"], b["token"]);
+ let old_beat = json!({"sessionId": a["sessionId"], "token": a["token"]});
+ assert_eq!(h_heartbeat(&mut state, IP, &old_beat).0, 403, "the old token still beats");
+ let new_beat = json!({"sessionId": b["sessionId"], "token": b["token"]});
+ assert_eq!(h_heartbeat(&mut state, IP, &new_beat).0, 200);
+}
+
+#[test]
+fn a_proved_create_takes_over_an_unproved_row_listing_its_key_and_a_refused_one_does_not() {
+ let (env, mut state, h) = (Env::new(), MasterState::new(), host());
+ let (_, squat) = env.create(&mut state, "203.0.113.5", &plain(&h, OLD));
+ // A refused proved create (forged) leaves the squatter's row alone.
+ let nonce = env.nonce(&mut state, IP);
+ let mut forged = plain(&h, NEW);
+ forged["nonce"] = json!(nonce);
+ forged["proof"] = json!(sign(&host(), &nonce, NAME));
+ assert_eq!(env.create(&mut state, IP, &forged).0, 400);
+ assert_eq!(state.lobbies.len(), 1);
+ // The real holder's create replaces it, from another address.
+ let real = env.signed(&mut state, IP, &h, NEW, NAME);
+ let (status, answer) = env.create(&mut state, IP, &real);
+ assert_eq!(status, 200);
+ assert_eq!(state.lobbies.len(), 1);
+ assert_eq!(answer["lobbyId"], squat["lobbyId"]);
+ let rows = build_rows(&state);
+ assert_eq!(rows_of(&rows, &h)[0]["proved"], true);
+}
+
+#[test]
+fn a_replace_passes_the_per_address_cap_that_a_new_row_would_not() {
+ let (env, h) = (Env::new(), host());
+ let (mut state, ip) = (MasterState::new(), "203.0.113.200");
+ let first = env.signed(&mut state, ip, &h, NEW, NAME);
+ assert_eq!(env.create(&mut state, ip, &first).0, 200);
+ for _ in 0..7 {
+ // The rest of the address's 8 rows, put in directly so the create rate limit is not spent.
+ let lo = Lobby::new(ip);
+ state.lobby_by_public.insert(lo.lobby_id.clone(), lo.session_id.clone());
+ state.lobbies.insert(lo.session_id.clone(), lo);
+ }
+ assert_eq!(state.lobbies.len(), 8);
+ assert_eq!(env.create(&mut state, ip, &plain(&host(), OLD)).0, 429, "a 9th new row was listed");
+ let restart = env.signed(&mut state, ip, &h, NEW, NAME);
+ assert_eq!(env.create(&mut state, ip, &restart).0, 200, "a restart was refused at the address cap");
+ assert_eq!(state.lobbies.len(), 8);
+}
+
+#[test]
+fn a_replace_at_the_global_cap_evicts_nobody() {
+ let (env, h) = (Env::new(), host());
+ let (mut state, sessions) = full_table(|_| true, env.now);
+ state.lobbies.get_mut(&sessions[500]).unwrap().host_identity = h.ident.clone(); // not the stalest
+ let body = env.signed(&mut state, IP, &h, NEW, NAME);
+ assert_eq!(env.create(&mut state, IP, &body).0, 200);
+ assert_eq!(state.lobbies.len(), MAX_LOBBIES_GLOBAL);
+ assert!(!state.lobbies.contains_key(&sessions[500]), "the old session of the key lived on");
+ let bystanders = sessions.iter().enumerate().filter(|(i, _)| *i != 500).map(|(_, s)| s);
+ assert!(bystanders.into_iter().all(|s| state.lobbies.contains_key(s)), "a replace evicted a bystander");
+}
+
+#[test]
+fn the_eight_row_cap_per_address_still_holds_for_unproved_rows() {
+ let (env, mut state) = (Env::new(), MasterState::new());
+ for n in 0..8 {
+ assert_eq!(env.create(&mut state, "203.0.113.50", &plain(&host(), OLD)).0, 200, "row {n}");
+ }
+ let (status, answer) = env.create(&mut state, "203.0.113.50", &plain(&host(), OLD));
+ assert_eq!((status, answer["error"].as_str()), (429, Some("too many lobbies for this address")));
+}
+
+#[test]
+fn a_blocked_identity_is_refused_403_blocked_proved_or_not() {
+ let (mut env, mut state, h) = (Env::new(), MasterState::new(), host());
+ env.blocked = vec![h.ident.clone()];
+ let signed = env.signed(&mut state, IP, &h, NEW, NAME);
+ assert_eq!(error_of(&env.create(&mut state, IP, &signed)), (403, "blocked"));
+ assert_eq!(state.nonces.spent_len(), 1, "the proved create spent its nonce");
+ assert_eq!(error_of(&env.create(&mut state, IP, &plain(&h, OLD))), (403, "blocked"), "an unproved create got through");
+ assert!(state.lobbies.is_empty(), "a blocked create was listed");
+}
+
+#[test]
+fn a_create_that_cannot_prove_the_key_does_not_learn_it_is_blocked() {
+ let (mut env, mut state, h) = (Env::new(), MasterState::new(), host());
+ env.blocked = vec![h.ident.clone()];
+ assert_eq!(error_of(&env.create(&mut state, IP, &plain(&h, NEW))), (409, "proof-required"), "an unsigned create learned the list");
+ let nonce = env.nonce(&mut state, IP);
+ let mut forged = plain(&h, NEW);
+ forged["nonce"] = json!(nonce);
+ forged["proof"] = json!(sign(&host(), &nonce, NAME));
+ assert_eq!(error_of(&env.create(&mut state, IP, &forged)), (400, "bad-proof"), "a forged proof learned the list");
+}
+
+#[test]
+fn a_block_list_refuses_only_the_identities_it_names() {
+ let (mut env, mut state, victim, other) = (Env::new(), MasterState::new(), host(), host());
+ env.blocked = vec![victim.ident.clone()];
+ let body = env.signed(&mut state, IP, &other, NEW, NAME);
+ assert_eq!(env.create(&mut state, IP, &body).0, 200, "an unlisted identity was refused");
+}
diff --git a/server/src/lobby_proof.rs b/server/src/lobby_proof.rs
new file mode 100644
index 000000000..6545b31f4
--- /dev/null
+++ b/server/src/lobby_proof.rs
@@ -0,0 +1,487 @@
+//! The lobby announce proof. A lobby's identity IS an Ed25519 public key, so its host signs a
+//! challenge the master issued, together with the master's own name, before the row may be
+//! marked proved. The decision lives here, apart from the handler's I/O, so known-answer vectors
+//! can watch it refuse (the shape of `registration.rs`). The nonces it signs over are minted and
+//! spent by `Nonces`, below.
+
+use crate::common::{ct_eq, hex_to_bytes};
+use crate::version_gate::proof_required;
+use ring::hmac;
+use ring::rand::{SecureRandom, SystemRandom};
+use std::collections::{HashSet, VecDeque};
+use std::time::{Duration, Instant};
+
+// MTA's announce is one unsigned GET (CMasterServerAnnouncer.h:235-267) that every client then
+// checks by querying the announced port (Client/core/ServerBrowser/CServerList.cpp:490-595). A P2P
+// host is reached only through the relay and cannot be dialled, so the host signs a challenge the master
+// issued, the direction of MTA's own server-side challenge route (CHTTPD.cpp:131-170).
+
+// Domain tag of the announce proof. It differs from the signaling registration's
+// (`multivoid-signaling-register-v1`) and the peer admission's (`multivoid-peer-admission-v1`) in
+// its first bytes, so no choice of nonce can make a blob signed for one the blob of another: all
+// three are signed with the peer's one durable key.
+//
+// The blob is tag || identity || nonce || master_name. The identity is exactly 68 characters and
+// the nonce exactly 64, so the master name that follows cannot shift into either. The name is
+// what defeats a relay through a rogue master: a host that dialled the rogue signs the rogue's
+// name, which no honest master accepts.
+pub const ANNOUNCE_TAG: &[u8] = b"multivoid-lobby-announce-v1";
+
+/// The bytes an announce signs: tag || identity || nonce || master_name.
+pub fn blob(ident: &str, nonce: &str, master_name: &str) -> Vec {
+ let mut blob = Vec::with_capacity(ANNOUNCE_TAG.len() + ident.len() + nonce.len() + master_name.len());
+ blob.extend_from_slice(ANNOUNCE_TAG);
+ blob.extend_from_slice(ident.as_bytes());
+ blob.extend_from_slice(nonce.as_bytes());
+ blob.extend_from_slice(master_name.as_bytes());
+ blob
+}
+
+/// The announce proof's whole DECISION. `ident` must already have passed `identity_shape_ok`;
+/// `nonce` is the 64-hex value this master issued to the caller's address bucket, and
+/// `master_name` the name the caller is taken to have dialled (lower-case, port stripped);
+/// `proof_hex` is the 128-hex signature the host sent.
+pub fn check_lobby_proof(
+ ident: &str,
+ nonce: &str,
+ master_name: &str,
+ proof_hex: &str,
+) -> Result<(), &'static str> {
+ let hex = ident.strip_prefix("gen:").ok_or("not a key identity")?;
+ let pubkey = hex_to_bytes::<32>(hex).ok_or("key identity did not decode")?;
+ let sig = hex_to_bytes::<64>(proof_hex).ok_or("malformed proof")?;
+ ring::signature::UnparsedPublicKey::new(&ring::signature::ED25519, &pubkey)
+ .verify(&blob(ident, nonce, master_name), &sig)
+ .map_err(|_| "does not hold the key this identity names")
+}
+
+// ---- the nonce ----------------------------------------------------------------------------
+
+/// How long, at the least, an issued nonce may be answered. The expiry is carried in whole
+/// seconds of the master's own clock, so a nonce lives between `NONCE_LIFE` and one second more.
+pub const NONCE_LIFE: Duration = Duration::from_secs(30);
+/// The most spent nonces the master remembers, sized to MEMORY, not to the worst inflow: the master
+/// runs under `MemoryMax=128M` (`tools/vps_provision.sh`'s unit template), and an entry costs about
+/// 150 bytes across the set and its expiry queue, so 65,536 entries are about 10 MB. A set that is
+/// full answers `503 busy` to creates and does NOT forget: this is not MTA's flood table, which
+/// stops counting past 100 entries (`reference/mtasa-blue/Server/mods/deathmatch/logic/ASE.cpp:183`) because a forgotten
+/// counter costs it only a count, whereas a nonce forgotten before its expiry spends twice. No
+/// finite size covers every inflow, since the rate table forgets its oldest buckets at its cap, so
+/// the residual is said: filling the set takes more than 2,100 proved-shape creates a second for
+/// 31 s (a create spends one nonce, remembered `NONCE_LIFE` + 1 s), about 12,700 address buckets
+/// each at its own create budget -- a botnet's price, and only creates are refused meanwhile.
+pub const MAX_SPENT: usize = 65_536;
+/// The bytes one remembered nonce is budgeted at (the set's entry and its queue entry), for the
+/// memory pin below.
+pub const SPENT_ENTRY_BYTES: usize = 160;
+
+// Domain tag of the nonce's MAC: a nonce is not a signature over anything else this key makes.
+const NONCE_TAG: &[u8] = b"multivoid-lobby-nonce-v1";
+const MAC_LEN: usize = 16;
+
+/// Why a nonce was not spent.
+#[derive(Debug, PartialEq, Eq)]
+pub enum NonceFault {
+ /// Not minted here for this address bucket (its MAC fails), expired, or already spent.
+ Invalid,
+ /// The spent set holds `MAX_SPENT` live entries.
+ Full,
+}
+
+/// The nonces a create signs. A nonce is `expiry (8, big-endian seconds) || random (8) || MAC
+/// (16)`, the MAC an HMAC-SHA256 under a secret drawn when the master starts, over the tag, the
+/// address bucket it was issued to, the expiry and the random part. Issuing stores nothing, so
+/// no number of challenges can fill anything; only a nonce that was SPENT is kept, until its
+/// expiry, so it spends once. MTA's announce carries no nonce (CMasterServerAnnouncer.h:235-267), so
+/// there is no precedent to follow; the shape is the SYN cookie's: the server answers a token it can
+/// re-derive. A restart draws a new secret, which voids every outstanding nonce (a `410`, and
+/// the client asks again). The clock is passed in; seconds count from the master's start.
+pub struct Nonces {
+ rng: SystemRandom,
+ key: hmac::Key,
+ origin: Instant,
+ spent: HashSet<[u8; 32]>,
+ /// The spent nonces by expiry, ascending: the sweep pops the expired from the front.
+ order: VecDeque<(u64, [u8; 32])>,
+}
+
+impl Nonces {
+ pub fn new() -> Nonces {
+ let rng = SystemRandom::new();
+ let mut secret = [0u8; 32];
+ rng.fill(&mut secret).expect("OS CSPRNG unavailable");
+ Nonces {
+ rng,
+ key: hmac::Key::new(hmac::HMAC_SHA256, &secret),
+ origin: Instant::now(),
+ spent: HashSet::new(),
+ order: VecDeque::new(),
+ }
+ }
+
+ fn secs(&self, now: Instant) -> u64 {
+ now.saturating_duration_since(self.origin).as_secs()
+ }
+
+ fn mac(&self, bucket: &str, expiry_and_random: &[u8]) -> [u8; MAC_LEN] {
+ let mut msg = Vec::with_capacity(NONCE_TAG.len() + bucket.len() + expiry_and_random.len());
+ msg.extend_from_slice(NONCE_TAG);
+ msg.extend_from_slice(bucket.as_bytes());
+ msg.extend_from_slice(expiry_and_random);
+ let mut out = [0u8; MAC_LEN];
+ out.copy_from_slice(&hmac::sign(&self.key, &msg).as_ref()[..MAC_LEN]);
+ out
+ }
+
+ /// A fresh 64-hex nonce for `bucket`. Stores nothing.
+ pub fn issue(&self, bucket: &str, now: Instant) -> String {
+ let mut nonce = [0u8; 32];
+ nonce[..8].copy_from_slice(&(self.secs(now) + NONCE_LIFE.as_secs()).to_be_bytes());
+ self.rng.fill(&mut nonce[8..16]).expect("OS CSPRNG unavailable");
+ let mac = self.mac(bucket, &nonce[..16]);
+ nonce[16..].copy_from_slice(&mac);
+ nonce.iter().map(|b| format!("{b:02x}")).collect()
+ }
+
+ /// Spend `nonce` for `bucket`: only a nonce this master minted for this bucket, not yet
+ /// expired, never spent before. A try that fails changes nothing, so a caller cannot burn a
+ /// nonce it was not issued.
+ pub fn take(&mut self, nonce: &[u8; 32], bucket: &str, now: Instant) -> Result<(), NonceFault> {
+ let now_s = self.secs(now);
+ while let Some(&(expiry, old)) = self.order.front() {
+ if expiry >= now_s {
+ break;
+ }
+ self.order.pop_front();
+ self.spent.remove(&old);
+ }
+ let expiry = u64::from_be_bytes(nonce[..8].try_into().expect("8 bytes"));
+ if !ct_eq(&self.mac(bucket, &nonce[..16]), &nonce[16..]) || expiry < now_s || self.spent.contains(nonce) {
+ return Err(NonceFault::Invalid);
+ }
+ if self.spent.len() >= MAX_SPENT {
+ return Err(NonceFault::Full);
+ }
+ self.spent.insert(*nonce);
+ let at = self.order.partition_point(|&(e, _)| e <= expiry);
+ self.order.insert(at, (expiry, *nonce));
+ Ok(())
+ }
+
+ /// How many spent nonces are remembered.
+ pub fn spent_len(&self) -> usize {
+ self.spent.len()
+ }
+}
+
+
+// ---- the create's verdict -----------------------------------------------------------------
+
+/// Why a create's proof is refused, each with its HTTP answer.
+#[derive(Debug, PartialEq, Eq)]
+pub enum Refusal {
+ /// A build that signs sent no proof.
+ ProofRequired,
+ /// A proof that is malformed, or does not verify under any name this master answers to.
+ BadProof,
+ /// The nonce is not one this master minted for this address bucket, or it expired or was
+ /// already spent.
+ NonceExpired,
+ /// The spent set is full.
+ Busy,
+}
+
+impl Refusal {
+ pub fn answer(&self) -> (u16, &'static str) {
+ match self {
+ Refusal::ProofRequired => (409, "proof-required"),
+ Refusal::BadProof => (400, "bad-proof"),
+ Refusal::NonceExpired => (410, "nonce-expired"),
+ Refusal::Busy => (503, "busy"),
+ }
+ }
+}
+
+/// The verdict on a create body's `nonce` and `proof`: `Ok(true)` for a proved row, `Ok(false)`
+/// for an unproved one (a build below `MIN_PROVED_PROTO` that sent no proof), else the refusal.
+/// A proof that is present must verify whatever the build. The nonce is spent as soon as a
+/// well-formed proof presents it, whether or not the proof then verifies. The proof is tried
+/// against each name the master answers to: the body names none, the signature does.
+pub fn judge(
+ nonces: &mut Nonces,
+ names: &[String],
+ bucket: &str,
+ now: Instant,
+ body: &serde_json::Value,
+ identity: &str,
+ proto: i64,
+) -> Result {
+ let proof = match body.get("proof") {
+ None | Some(serde_json::Value::Null) => None,
+ Some(serde_json::Value::String(s)) => Some(s.as_str()),
+ Some(_) => return Err(Refusal::BadProof),
+ };
+ let Some(proof) = proof else {
+ return if proof_required(proto) { Err(Refusal::ProofRequired) } else { Ok(false) };
+ };
+ let nonce = body.get("nonce").and_then(serde_json::Value::as_str).unwrap_or("");
+ let (Some(nonce_bytes), Some(_)) = (hex_to_bytes::<32>(nonce), hex_to_bytes::<64>(proof)) else {
+ return Err(Refusal::BadProof);
+ };
+ match nonces.take(&nonce_bytes, bucket, now) {
+ Ok(()) => {}
+ Err(NonceFault::Invalid) => return Err(Refusal::NonceExpired),
+ Err(NonceFault::Full) => return Err(Refusal::Busy),
+ }
+ if names.iter().any(|name| check_lobby_proof(identity, nonce, name, proof).is_ok()) {
+ Ok(true)
+ } else {
+ Err(Refusal::BadProof)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::{
+ blob, check_lobby_proof, judge, NonceFault, Nonces, Refusal, ANNOUNCE_TAG, MAX_SPENT, NONCE_LIFE,
+ };
+ use crate::common::{hex_to_bytes, identity_shape_ok};
+ use ring::rand::SystemRandom;
+ use ring::signature::{Ed25519KeyPair, KeyPair};
+ use std::collections::HashSet;
+ use std::time::{Duration, Instant};
+
+ // The vector the client's selftest reads as well: one fixed seed, its identity, a nonce, a
+ // master name, the blob and the signature an independent Ed25519 implementation produced.
+ const VECTOR: &str = include_str!("../tests/vectors/lobby_proof.json");
+
+ const N1: &str = "1111111111111111111111111111111111111111111111111111111111111111";
+ const N2: &str = "2222222222222222222222222222222222222222222222222222222222222222";
+ const NAME: &str = "master.multivoid.dev";
+
+ struct Peer {
+ ident: String,
+ kp: Ed25519KeyPair,
+ }
+
+ fn ident_of(kp: &Ed25519KeyPair) -> String {
+ let mut ident = String::from("gen:");
+ for b in kp.public_key().as_ref() {
+ ident.push_str(&format!("{b:02x}"));
+ }
+ ident
+ }
+
+ /// A peer built from a REAL generated keypair, so the positive arm takes the path a live
+ /// client does.
+ fn peer() -> Peer {
+ let pkcs8 = Ed25519KeyPair::generate_pkcs8(&SystemRandom::new()).unwrap();
+ let kp = Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
+ Peer { ident: ident_of(&kp), kp }
+ }
+
+ fn hex(bytes: &[u8]) -> String {
+ bytes.iter().map(|b| format!("{b:02x}")).collect()
+ }
+
+ /// Sign `as_ident || nonce || name` under the announce tag and render the wire proof.
+ fn proof(p: &Peer, as_ident: &str, nonce: &str, name: &str) -> String {
+ hex(p.kp.sign(&blob(as_ident, nonce, name)).as_ref())
+ }
+
+ #[test]
+ fn the_shared_known_answer_vector_verifies_and_reproduces() {
+ let v: serde_json::Value = serde_json::from_str(VECTOR).unwrap();
+ let field = |k: &str| v[k].as_str().unwrap_or_else(|| panic!("vector has no {k}")).to_string();
+ assert_eq!(field("tag").as_bytes(), ANNOUNCE_TAG);
+ let (ident, nonce, name, sig) = (field("identity"), field("nonce"), field("master_name"), field("signature"));
+ assert!(identity_shape_ok(&ident));
+ assert_eq!(hex(&blob(&ident, &nonce, &name)), field("blob_hex"), "the blob layout moved");
+ assert!(check_lobby_proof(&ident, &nonce, &name, &sig).is_ok(), "the vector's own proof was refused");
+ // The seed behind it, signed here by ring, gives the same bytes: Ed25519 is deterministic.
+ let seed: Vec = (0..32).map(|i| u8::from_str_radix(&field("seed")[i * 2..i * 2 + 2], 16).unwrap()).collect();
+ let kp = Ed25519KeyPair::from_seed_unchecked(&seed).unwrap();
+ assert_eq!(ident_of(&kp), ident);
+ assert_eq!(hex(kp.sign(&blob(&ident, &nonce, &name)).as_ref()), sig);
+ // Each input of the blob is load-bearing: the same proof under any other one is refused.
+ assert!(check_lobby_proof(&ident, &nonce, "other.example", &sig).is_err());
+ assert!(check_lobby_proof(&ident, N1, &name, &sig).is_err());
+ }
+
+ #[test]
+ fn a_real_holder_proves() {
+ let p = peer();
+ assert!(identity_shape_ok(&p.ident), "the generated identity is not the shape the master gates on");
+ assert!(check_lobby_proof(&p.ident, N1, NAME, &proof(&p, &p.ident, N1, NAME)).is_ok());
+ }
+
+ #[test]
+ fn the_ways_to_not_hold_the_key_are_all_refused() {
+ let victim = peer();
+ let attacker = peer();
+ assert_ne!(victim.ident, attacker.ident, "two peers minted one identity -- every arm proves nothing");
+
+ // 1. SQUAT: the attacker signs a blob naming the VICTIM with its own key and lists the
+ // victim's identity.
+ assert!(check_lobby_proof(&victim.ident, N1, NAME, &proof(&attacker, &victim.ident, N1, NAME)).is_err());
+ // 2. REPLAY: a proof recorded for another nonce.
+ assert!(check_lobby_proof(&victim.ident, N2, NAME, &proof(&victim, &victim.ident, N1, NAME)).is_err());
+ // 3. ANOTHER MASTER: a proof minted for a rogue master's name verifies nowhere else.
+ assert!(check_lobby_proof(&victim.ident, N1, NAME, &proof(&victim, &victim.ident, N1, "rogue.example")).is_err());
+ // 4. ANOTHER KEY: the victim's valid proof offered for the attacker's identity.
+ assert!(check_lobby_proof(&attacker.ident, N1, NAME, &proof(&victim, &victim.ident, N1, NAME)).is_err());
+ // 5. NO PROOF, and the registration's wire form, which is not this one.
+ assert!(check_lobby_proof(&victim.ident, N1, NAME, "").is_err());
+ let good = proof(&victim, &victim.ident, N1, NAME);
+ assert!(check_lobby_proof(&victim.ident, N1, NAME, &format!("auth {good}")).is_err());
+ // 6. MALFORMED: short, long, uppercase (the alphabet is lowercase-only like the name it proves).
+ assert!(check_lobby_proof(&victim.ident, N1, NAME, &good[..good.len() - 2]).is_err());
+ assert!(check_lobby_proof(&victim.ident, N1, NAME, &format!("{good}00")).is_err());
+ assert!(check_lobby_proof(&victim.ident, N1, NAME, &good.to_uppercase()).is_err());
+ // 7. NOT A KEY IDENTITY.
+ assert!(check_lobby_proof("h0123456789abcdef", N1, NAME, &good).is_err());
+ }
+
+ #[test]
+ fn one_flipped_bit_is_refused() {
+ // The arm that fails if verification is ever reduced to a length check.
+ let p = peer();
+ let mut line = proof(&p, &p.ident, N1, NAME).into_bytes();
+ let last = line.len() - 1;
+ line[last] = if line[last] == b'0' { b'1' } else { b'0' };
+ assert!(check_lobby_proof(&p.ident, N1, NAME, &String::from_utf8(line).unwrap()).is_err());
+ }
+
+ #[test]
+ fn the_tag_differs_from_the_other_two_in_its_first_bytes() {
+ for other in [&b"multivoid-signaling-register-v1"[..], &b"multivoid-peer-admission-v1"[..]] {
+ let at = ANNOUNCE_TAG.iter().zip(other).position(|(a, b)| a != b).expect("tags share a prefix");
+ assert!(at <= 12, "the announce tag and {:?} share {at} leading bytes", String::from_utf8_lossy(other));
+ }
+ }
+
+ const A: &str = "198.51.100.1";
+ const B: &str = "198.51.100.2";
+ // Past the life by a margin the whole-second expiry cannot straddle.
+ const DEAD: Duration = Duration::from_secs(NONCE_LIFE.as_secs() + 2);
+
+ fn raw(nonce: &str) -> [u8; 32] {
+ hex_to_bytes::<32>(nonce).expect("a nonce is 64 lower-case hex")
+ }
+
+ /// `nonce` with the hex digit at `at` changed to another digit.
+ fn flip(nonce: &str, at: usize) -> String {
+ let mut b = nonce.as_bytes().to_vec();
+ b[at] = if b[at] == b'0' { b'1' } else { b'0' };
+ String::from_utf8(b).unwrap()
+ }
+
+ #[test]
+ fn a_nonce_is_64_hex_carries_its_expiry_and_is_spent_once() {
+ let (mut n, t0) = (Nonces::new(), Instant::now());
+ let nonce = n.issue(A, t0);
+ let bytes = raw(&nonce);
+ let expiry = u64::from_be_bytes(bytes[..8].try_into().unwrap());
+ assert_eq!(expiry, t0.saturating_duration_since(n.origin).as_secs() + NONCE_LIFE.as_secs());
+ assert_eq!(n.take(&bytes, A, t0), Ok(()));
+ assert_eq!(n.take(&bytes, A, t0), Err(NonceFault::Invalid), "a nonce was spent twice");
+ assert_eq!(n.spent_len(), 1);
+ }
+
+ #[test]
+ fn issuing_stores_nothing_and_never_repeats() {
+ let (n, t0) = (Nonces::new(), Instant::now());
+ let all: HashSet = (0..2000).map(|_| n.issue(A, t0)).collect();
+ assert_eq!(all.len(), 2000, "two challenges answered one nonce");
+ assert_eq!(n.spent_len(), 0, "issuing left something behind");
+ }
+
+ #[test]
+ fn a_nonce_lives_thirty_seconds_and_not_much_longer() {
+ let (mut n, t0) = (Nonces::new(), Instant::now());
+ let (early, late) = (n.issue(A, t0), n.issue(A, t0));
+ assert_eq!(n.take(&raw(&early), A, t0 + NONCE_LIFE), Ok(()), "a nonce died before its thirty seconds");
+ assert_eq!(n.take(&raw(&late), A, t0 + DEAD), Err(NonceFault::Invalid), "an expired nonce was spent");
+ assert_eq!(n.spent_len(), 0, "the sweep kept an expired nonce");
+ }
+
+ #[test]
+ fn another_buckets_nonce_is_refused_and_its_owner_keeps_it() {
+ let (mut n, t0) = (Nonces::new(), Instant::now());
+ let nonce = raw(&n.issue(A, t0));
+ assert_eq!(n.take(&nonce, B, t0), Err(NonceFault::Invalid));
+ assert_eq!(n.spent_len(), 0, "a stranger's try burnt the owner's nonce");
+ assert_eq!(n.take(&nonce, A, t0), Ok(()));
+ }
+
+ #[test]
+ fn a_nonce_with_any_flipped_digit_is_refused() {
+ let (mut n, t0) = (Nonces::new(), Instant::now());
+ let nonce = n.issue(A, t0);
+ // The expiry, the random part and the MAC: each part is covered by the MAC.
+ for at in [0, 15, 16, 31, 32, 47, 63] {
+ assert_eq!(n.take(&raw(&flip(&nonce, at)), A, t0), Err(NonceFault::Invalid), "digit {at}");
+ }
+ assert_eq!(n.take(&raw(&nonce), A, t0), Ok(()), "a refused try burnt the real nonce");
+ }
+
+ #[test]
+ fn a_master_with_another_secret_does_not_accept_the_nonce() {
+ let (mut one, mut two, t0) = (Nonces::new(), Nonces::new(), Instant::now());
+ let nonce = raw(&one.issue(A, t0));
+ assert_eq!(two.take(&nonce, A, t0), Err(NonceFault::Invalid));
+ assert_eq!(one.take(&nonce, A, t0), Ok(()));
+ }
+
+ #[test]
+ fn a_spent_nonce_is_forgotten_at_its_expiry_and_in_expiry_order() {
+ let (mut n, t0) = (Nonces::new(), Instant::now());
+ // Spent out of issue order: the later-expiring one first.
+ let later = raw(&n.issue(A, t0 + Duration::from_secs(10)));
+ let sooner = raw(&n.issue(A, t0));
+ let t = t0 + Duration::from_secs(12);
+ assert_eq!((n.take(&later, A, t), n.take(&sooner, A, t)), (Ok(()), Ok(())));
+ assert_eq!(n.order.iter().map(|e| e.0).collect::>(), {
+ let mut v: Vec = n.order.iter().map(|e| e.0).collect();
+ v.sort_unstable();
+ v
+ });
+ // Past the first's expiry only the first goes; past both, only the newest is left.
+ let fresh = raw(&n.issue(A, t0 + Duration::from_secs(35)));
+ assert_eq!(n.take(&fresh, A, t0 + Duration::from_secs(35)), Ok(()));
+ assert_eq!(n.spent_len(), 2, "the sweep dropped a live nonce or kept a dead one");
+ let last = raw(&n.issue(A, t0 + Duration::from_secs(100)));
+ assert_eq!(n.take(&last, A, t0 + Duration::from_secs(100)), Ok(()));
+ assert_eq!(n.spent_len(), 1);
+ assert_eq!(n.order.len(), 1);
+ }
+
+ #[test]
+ fn a_full_spent_set_answers_busy_until_the_sweep_frees_it() {
+ let (mut n, t0) = (Nonces::new(), Instant::now());
+ for _ in 0..MAX_SPENT {
+ assert_eq!(n.take(&raw(&n.issue(A, t0)), A, t0), Ok(()));
+ }
+ let next = raw(&n.issue(A, t0));
+ assert_eq!(n.take(&next, A, t0), Err(NonceFault::Full));
+ assert_eq!(n.spent_len(), MAX_SPENT);
+ let after = t0 + DEAD;
+ let fresh = raw(&n.issue(A, after));
+ assert_eq!(n.take(&fresh, A, after), Ok(()), "the sweep did not free a full set");
+ assert_eq!(n.spent_len(), 1);
+ }
+
+ #[test]
+ fn judge_answers_each_nonce_fault_with_its_status() {
+ let (mut n, t0, p) = (Nonces::new(), Instant::now(), peer());
+ let names = vec![NAME.to_string()];
+ let body = |nonce: &str| serde_json::json!({"nonce": nonce, "proof": proof(&p, &p.ident, nonce, NAME)});
+ let nonce = n.issue(A, t0);
+ let go = |n: &mut Nonces, bucket: &str, b: &serde_json::Value| judge(n, &names, bucket, t0, b, &p.ident, 230);
+ assert_eq!(go(&mut n, B, &body(&nonce)), Err(Refusal::NonceExpired));
+ assert_eq!(go(&mut n, A, &body(&nonce)), Ok(true));
+ assert_eq!(go(&mut n, A, &body(&nonce)), Err(Refusal::NonceExpired), "a replay was judged");
+ assert_eq!(Refusal::NonceExpired.answer(), (410, "nonce-expired"));
+ assert_eq!(Refusal::Busy.answer(), (503, "busy"));
+ }
+}
diff --git a/server/src/master_config.rs b/server/src/master_config.rs
index 7ba422a92..8ca6e7be2 100644
--- a/server/src/master_config.rs
+++ b/server/src/master_config.rs
@@ -1,7 +1,7 @@
//! The master's own environment: the ports and the secrets it refuses to start without.
//! Read once, at first use, and never mutated -- so every module reads one snapshot.
-use crate::common::{env_int, env_str};
+use crate::common::{canon_host_name, env_int, env_str, identity_shape_ok, log};
use std::sync::LazyLock;
pub struct Config {
@@ -11,10 +11,142 @@ pub struct Config {
pub signaling_url: String,
pub stun_uri: String,
pub turn_uri: String,
+ /// The names a lobby proof may have been signed for: `COOP_DOMAIN` first, then each of
+ /// `COOP_MASTER_NAME_ALIASES`. `COOP_DOMAIN` is required, so a master that started holds one.
+ pub public_names: Vec,
+ /// `COOP_MIN_LISTED_PROTO`: a create below it is refused. 0 (the default) refuses nothing.
+ pub min_listed_proto: i64,
+ /// `COOP_BLOCKED_IDENTITIES`: key identities (`gen:<64 hex>`) the operator keeps off the list.
+ pub blocked_identities: Vec,
+}
+
+/// Whether a (canonical) name points back at the machine that dials it: `localhost`, a 0.0.0.0 or
+/// 127.x literal (the short forms `127.1` and `127.0.1` too, which the system resolver reads as
+/// loopback), the IPv6 loopback and its IPv4-mapped form. Such a name is a dev master's, never a
+/// production one's.
+pub fn is_loopback_name(name: &str) -> bool {
+ if name == "localhost" || name.ends_with(".localhost") || name == "0.0.0.0" {
+ return true;
+ }
+ let inner = name.strip_prefix('[').and_then(|n| n.strip_suffix(']')).unwrap_or(name);
+ if inner == "::1" {
+ return true;
+ }
+ let v4 = inner.strip_prefix("::ffff:").unwrap_or(inner);
+ let octets: Vec<&str> = v4.split('.').collect();
+ (2..=4).contains(&octets.len())
+ && octets.iter().all(|o| !o.is_empty() && o.len() <= 3 && o.bytes().all(|b| b.is_ascii_digit()))
+ && octets[0].parse::() == Ok(127)
+}
+
+/// The names a master answers to: `domain` (canonicalised, kept whatever it is) then the comma
+/// list `aliases`, each trimmed and lower-cased. An alias that is empty is skipped, a loopback
+/// one is refused and named in the second list (the caller logs it), a repeat is dropped.
+pub fn parse_public_names(domain: &str, aliases: &str) -> (Vec, Vec) {
+ let mut names = Vec::new();
+ let mut refused = Vec::new();
+ let domain = canon_host_name(domain);
+ if !domain.is_empty() {
+ names.push(domain);
+ }
+ for alias in aliases.split(',').map(canon_host_name).filter(|a| !a.is_empty()) {
+ if is_loopback_name(&alias) {
+ refused.push(alias);
+ } else if !names.contains(&alias) {
+ names.push(alias);
+ }
+ }
+ (names, refused)
+}
+
+/// Why a written name is not a bare host name, or None. The name a client signs is the host of the
+/// URL it dialled, so a URL, a path, a login, a bracketed literal or a ':' left after the optional
+/// port would match no client for good while the master ran and answered `/healthz`: a production
+/// master's name is a DNS name or an IPv4 literal. `with_port` lets one trailing ':digits' through
+/// (an alias is often copied from a `host:port` entry; it is stripped when names are compared).
+fn name_fault(written: &str, with_port: bool) -> Option<&'static str> {
+ let s = written.trim();
+ if s.contains("://") || s.contains(['/', '@', '[', ']']) || s.chars().any(char::is_whitespace) {
+ return Some("is not a bare host name (no scheme, path, login, brackets or spaces)");
+ }
+ if !s.is_ascii() {
+ return Some("is not a bare host name (plain ASCII only)");
+ }
+ let host = match s.rsplit_once(':') {
+ Some((h, p)) if with_port && !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit()) => h,
+ _ => s,
+ };
+ if host.contains(':') {
+ return Some(if with_port {
+ "is not a bare host name (no IPv6 literal; a port is digits only)"
+ } else {
+ "is not a bare host name (no ':' at all: no port, no IPv6 literal)"
+ });
+ }
+ if host.strip_suffix('.').unwrap_or(host).is_empty() {
+ return Some("is not a bare host name (empty)");
+ }
+ None
+}
+
+/// What is wrong with the master's settings, one line each; empty when it may start. `domain` is
+/// `COOP_DOMAIN` as written and is required (aliases are additional names, never a substitute);
+/// `aliases` is `COOP_MASTER_NAME_ALIASES` as written, every non-empty entry held to the same rules
+/// as the domain bar an allowed ':port'. `floor` is `COOP_MIN_LISTED_PROTO` as written ("" when
+/// unset): a value that is not a decimal number would otherwise read as 0 and silently lift it.
+pub fn config_faults(domain: &str, aliases: &str, floor: &str) -> Vec {
+ let mut faults = Vec::new();
+ let d = domain.trim();
+ if d.is_empty() {
+ faults.push("COOP_DOMAIN not set -- a lobby proof names its master and this one has no name".to_string());
+ } else if let Some(why) = name_fault(d, false) {
+ faults.push(format!("COOP_DOMAIN '{d}' {why}"));
+ }
+ for a in aliases.split(',').map(str::trim).filter(|a| !a.is_empty()) {
+ if let Some(why) = name_fault(a, true) {
+ faults.push(format!("COOP_MASTER_NAME_ALIASES entry '{a}' {why}"));
+ }
+ }
+ let f = floor.trim();
+ if !f.is_empty() && !(f.bytes().all(|b| b.is_ascii_digit()) && f.parse::().is_ok()) {
+ faults.push(format!("COOP_MIN_LISTED_PROTO '{f}' is not a decimal number"));
+ }
+ faults
+}
+
+/// The identities of `COOP_BLOCKED_IDENTITIES` as written: a comma list, each entry trimmed, an empty
+/// one skipped, a repeat dropped. An entry that is not `gen:<64 lower-case hex>` is named in the
+/// second list (the caller refuses to start on it: a block that silently matched nothing is worse
+/// than none).
+pub fn parse_blocked_identities(spec: &str) -> (Vec, Vec) {
+ let mut blocked = Vec::new();
+ let mut invalid = Vec::new();
+ for entry in spec.split(',').map(str::trim).filter(|e| !e.is_empty()) {
+ if !identity_shape_ok(entry) {
+ invalid.push(entry.to_string());
+ } else if !blocked.iter().any(|b| b == entry) {
+ blocked.push(entry.to_string());
+ }
+ }
+ (blocked, invalid)
+}
+
+/// What is wrong with `COOP_BLOCKED_IDENTITIES` as written, one line each; empty when it may start.
+pub fn blocked_faults(spec: &str) -> Vec {
+ parse_blocked_identities(spec)
+ .1
+ .iter()
+ .map(|e| format!("COOP_BLOCKED_IDENTITIES entry '{e}' is not a key identity (gen: and 64 lower-case hex)"))
+ .collect()
}
impl Config {
fn from_env() -> Config {
+ let (public_names, refused) =
+ parse_public_names(&env_str("COOP_DOMAIN", ""), &env_str("COOP_MASTER_NAME_ALIASES", ""));
+ for alias in &refused {
+ log(&format!("WARN: COOP_MASTER_NAME_ALIASES entry '{alias}' refused (a loopback name)"));
+ }
Config {
port: env_int("COOP_MASTER_PORT", 10001) as u16,
turn_secret: env_str("COOP_TURN_SECRET", ""),
@@ -22,8 +154,142 @@ impl Config {
signaling_url: env_str("COOP_SIGNALING_URL", ""),
stun_uri: env_str("COOP_STUN_URI", ""),
turn_uri: env_str("COOP_TURN_URI", ""),
+ public_names,
+ min_listed_proto: env_int("COOP_MIN_LISTED_PROTO", 0).max(0),
+ blocked_identities: parse_blocked_identities(&env_str("COOP_BLOCKED_IDENTITIES", "")).0,
}
}
}
pub static CFG: LazyLock = LazyLock::new(Config::from_env);
+
+#[cfg(test)]
+mod tests {
+ use super::{blocked_faults, config_faults, is_loopback_name, parse_blocked_identities, parse_public_names};
+
+ #[test]
+ fn loopback_names_are_recognised() {
+ for n in [
+ "localhost", "dev.localhost", "127.0.0.1", "127.1.2.3", "0.0.0.0", "::1", "[::1]", "127.1", "127.0.1",
+ "::ffff:127.0.0.1", "[::ffff:127.0.0.1]",
+ ] {
+ assert!(is_loopback_name(n), "{n} should be loopback");
+ }
+ for n in [
+ "master.multivoid.dev", "203.0.113.7", "128.0.0.1", "1270.0.0.1", "127", "128.1", "::ffff:128.0.0.1",
+ "localhost.example",
+ ] {
+ assert!(!is_loopback_name(n), "{n} should not be loopback");
+ }
+ }
+
+ #[test]
+ fn the_domain_comes_first_and_aliases_are_trimmed_and_lower_cased() {
+ let (names, refused) =
+ parse_public_names(" Master.Multivoid.dev ", " 203.0.113.7:10443 , Dev.Example ,, master.multivoid.dev");
+ assert_eq!(names, vec!["master.multivoid.dev", "203.0.113.7", "dev.example"]);
+ assert!(refused.is_empty());
+ }
+
+ #[test]
+ fn a_loopback_alias_is_refused_and_named() {
+ let (names, refused) = parse_public_names("master.multivoid.dev", "localhost,127.0.0.1:1,203.0.113.7");
+ assert_eq!(names, vec!["master.multivoid.dev", "203.0.113.7"]);
+ assert_eq!(refused, vec!["localhost", "127.0.0.1"]);
+ }
+
+ #[test]
+ fn no_domain_and_no_alias_is_no_name() {
+ assert!(parse_public_names("", "").0.is_empty());
+ assert!(parse_public_names(" ", " , ").0.is_empty());
+ }
+
+ #[test]
+ fn a_domain_must_be_a_bare_host_and_a_floor_a_decimal_number() {
+ for d in ["master.multivoid.dev", " Master.Multivoid.dev ", "master.multivoid.dev.", "203.0.113.7"] {
+ assert!(config_faults(d, "", "").is_empty(), "'{d}' was refused");
+ }
+ for d in [
+ "https://master.multivoid.dev",
+ "master.multivoid.dev/v1",
+ "user@master.multivoid.dev",
+ "master.multivoid.dev:10443",
+ "master.multivoid.dev:abc",
+ "[::1]:10443",
+ "[2001:db8::1]",
+ "2001:db8::1",
+ "master multivoid.dev",
+ "b\u{fc}cher.example",
+ ".",
+ ] {
+ assert_eq!(config_faults(d, "", "").len(), 1, "'{d}' was accepted");
+ }
+ for f in ["", " ", "0", "230", " 230 "] {
+ assert!(config_faults("a.example", "", f).is_empty(), "floor '{f}' was refused");
+ }
+ for f in ["abc", "23x", "-1", "2.5", "99999999999999999999"] {
+ assert_eq!(config_faults("a.example", "", f).len(), 1, "floor '{f}' was accepted");
+ }
+ assert_eq!(config_faults("https://a.example", "", "x").len(), 2, "both faults are named");
+ }
+
+ #[test]
+ fn the_domain_is_required_and_aliases_do_not_stand_in_for_it() {
+ for d in ["", " "] {
+ assert_eq!(config_faults(d, "", "").len(), 1, "'{d}' was accepted");
+ assert_eq!(config_faults(d, "203.0.113.7", "").len(), 1, "an alias stood in for '{d}'");
+ }
+ }
+
+ #[test]
+ fn an_alias_is_held_to_the_domains_rules_and_may_carry_a_port() {
+ for a in ["", " , ,", "203.0.113.7:10443", " Dev.Example , 203.0.113.7:1 ", "dev.example.", "localhost,127.0.0.1:1"] {
+ assert!(config_faults("a.example", a, "").is_empty(), "aliases '{a}' were refused");
+ }
+ for a in [
+ "https://dev.example",
+ "dev.example/v1",
+ "user@dev.example",
+ "dev.example:abc",
+ "dev.example:",
+ "dev.example:1:2",
+ "[::1]",
+ "[2001:db8::1]:10443",
+ "2001:db8::1",
+ "dev example",
+ "b\u{fc}cher.example",
+ ] {
+ assert_eq!(config_faults("a.example", a, "").len(), 1, "alias '{a}' was accepted");
+ }
+ let two = config_faults("a.example", "ok.example,https://x.example,y.example:abc", "");
+ assert_eq!(two.len(), 2, "each bad entry is named: {two:?}");
+ assert!(two[0].contains("https://x.example") && two[1].contains("y.example:abc"));
+ }
+
+ #[test]
+ fn the_block_list_is_a_trimmed_comma_list_of_key_identities() {
+ let (a, b) = (format!("gen:{}", "ab".repeat(32)), format!("gen:{}", "01".repeat(32)));
+ let (blocked, invalid) = parse_blocked_identities(&format!(" {a} ,, {b},{a}"));
+ assert_eq!(blocked, vec![a, b]);
+ assert!(invalid.is_empty());
+ assert!(parse_blocked_identities("").0.is_empty() && parse_blocked_identities(" , ").0.is_empty());
+ assert!(blocked_faults("").is_empty());
+ }
+
+ #[test]
+ fn a_block_entry_that_is_not_a_key_identity_refuses_the_start() {
+ let good = format!("gen:{}", "ab".repeat(32));
+ for bad in [
+ "h0123456789abcdef".to_string(),
+ "ab".repeat(32),
+ format!("gen:{}", "AB".repeat(32)),
+ format!("gen:{}", "ab".repeat(31)),
+ format!("{good}0"),
+ ] {
+ let faults = blocked_faults(&format!("{good},{bad}"));
+ assert_eq!(faults.len(), 1, "'{bad}' was accepted: {faults:?}");
+ assert!(faults[0].contains(&bad));
+ }
+ assert_eq!(blocked_faults("x,y").len(), 2, "each bad entry is named");
+ }
+}
diff --git a/server/src/rate_table.rs b/server/src/rate_table.rs
new file mode 100644
index 000000000..1a49af926
--- /dev/null
+++ b/server/src/rate_table.rs
@@ -0,0 +1,203 @@
+//! The master's per-(address, class) sliding windows, in a table that never refuses a key.
+//!
+//! The table holds at most `cap` keys. When a key it does not hold arrives at the cap, the key
+//! seen LEAST RECENTLY is forgotten to make room, so a flood of new addresses fills the table
+//! with the flooders and costs the oldest of them their window; it never refuses a request from
+//! an address the table does not hold. The precedent bounds its mitigation too: once its own
+//! table holds 100 flooding entries MTA stops counting queries per address and answers the rest
+//! (`reference/mtasa-blue/Server/mods/deathmatch/logic/ASE.cpp:183`); this table keeps counting
+//! every key it holds and forgets the oldest, the stricter shape, so the mitigation cannot become
+//! the sink.
+//!
+//! Recency is a queue of (stamp, key) pushed on every request; a key's live entry is the one
+//! whose stamp equals the stamp its slot holds, and the older ones are dead. Eviction pops the
+//! front until it finds a live entry, so each pushed entry is popped once (O(1) amortised, no
+//! scan per request), and the queue is rebuilt when it outgrows twice the table, which keeps
+//! its memory bounded by the same amortisation. Entries that stopped counting (older than every
+//! window) are the least recently seen, so they leave first; no separate expiry scan is needed.
+
+use std::collections::{HashMap, VecDeque};
+use std::sync::Arc;
+use std::time::{Duration, Instant};
+
+/// Dead entries the queue may hold beyond twice the table before it is rebuilt.
+const SLACK: usize = 1024;
+
+struct Slot {
+ name: Arc,
+ stamps: Vec,
+ /// The stamp of this key's live entry in the recency queue.
+ seq: u64,
+}
+
+pub struct RateTable {
+ cap: usize,
+ slots: HashMap, Slot>,
+ recency: VecDeque<(u64, Arc)>,
+ next_seq: u64,
+ forgotten: u64,
+}
+
+impl RateTable {
+ pub fn new(cap: usize) -> RateTable {
+ RateTable { cap: cap.max(1), slots: HashMap::new(), recency: VecDeque::new(), next_seq: 0, forgotten: 0 }
+ }
+
+ pub fn len(&self) -> usize {
+ self.slots.len()
+ }
+
+ pub fn is_empty(&self) -> bool {
+ self.slots.is_empty()
+ }
+
+ pub fn contains(&self, key: &str) -> bool {
+ self.slots.contains_key(key)
+ }
+
+ /// Count one event of `key` against `limit` events per `window`: true when it is within the
+ /// limit. A request that is refused still counts as the key being seen.
+ pub fn hit(&mut self, key: &str, now: Instant, window: Duration, limit: usize) -> bool {
+ if !self.slots.contains_key(key) {
+ self.make_room();
+ let name: Arc = Arc::from(key);
+ self.slots.insert(Arc::clone(&name), Slot { name, stamps: Vec::new(), seq: 0 });
+ }
+ let seq = self.next_seq;
+ self.next_seq += 1;
+ let slot = self.slots.get_mut(key).expect("the slot was just ensured");
+ slot.seq = seq;
+ self.recency.push_back((seq, Arc::clone(&slot.name)));
+ slot.stamps.retain(|ts| now.duration_since(*ts) < window);
+ let within = slot.stamps.len() < limit;
+ if within {
+ slot.stamps.push(now);
+ }
+ if self.recency.len() > 2 * self.slots.len() + SLACK {
+ self.rebuild_recency();
+ }
+ within
+ }
+
+ /// Forget every key whose last counted event is older than `max_window` (the longest window
+ /// of any class). The master's sweeper calls this on a timer.
+ pub fn prune(&mut self, now: Instant, max_window: Duration) {
+ self.slots.retain(|_, s| s.stamps.last().is_some_and(|last| now.duration_since(*last) < max_window));
+ self.rebuild_recency();
+ }
+
+ /// How many keys were forgotten for room since the last call (the sweeper logs it).
+ pub fn take_forgotten(&mut self) -> u64 {
+ std::mem::take(&mut self.forgotten)
+ }
+
+ fn live(&self, seq: u64, name: &Arc) -> bool {
+ self.slots.get(&**name).is_some_and(|s| s.seq == seq)
+ }
+
+ /// Make room for one more key: forget the least recently seen until the table is under its cap.
+ fn make_room(&mut self) {
+ while self.slots.len() >= self.cap {
+ let Some((seq, name)) = self.recency.pop_front() else { break };
+ if self.live(seq, &name) {
+ self.slots.remove(&*name);
+ self.forgotten += 1;
+ }
+ }
+ }
+
+ fn rebuild_recency(&mut self) {
+ let slots = &self.slots;
+ self.recency.retain(|(seq, name)| slots.get(&**name).is_some_and(|s| s.seq == *seq));
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::{RateTable, SLACK};
+ use std::time::{Duration, Instant};
+
+ const W: Duration = Duration::from_secs(60);
+
+ fn hit(t: &mut RateTable, key: &str, now: Instant) -> bool {
+ t.hit(key, now, W, 10)
+ }
+
+ #[test]
+ fn a_window_counts_its_limit_and_then_refuses() {
+ let (mut t, now) = (RateTable::new(8), Instant::now());
+ for _ in 0..10 {
+ assert!(hit(&mut t, "a|create", now));
+ }
+ assert!(!hit(&mut t, "a|create", now), "the eleventh event inside the window passed");
+ assert!(hit(&mut t, "a|create", now + W), "the window did not slide");
+ }
+
+ #[test]
+ fn a_new_key_at_the_cap_is_admitted_and_the_least_recently_seen_forgotten() {
+ let (mut t, now) = (RateTable::new(3), Instant::now());
+ for k in ["a", "b", "c"] {
+ assert!(hit(&mut t, k, now));
+ }
+ assert!(hit(&mut t, "a", now), "a known key was refused");
+ assert!(hit(&mut t, "d", now), "a new key was refused at the cap");
+ assert_eq!(t.len(), 3);
+ assert!(!t.contains("b"), "the least recently seen key was kept");
+ assert!(t.contains("a") && t.contains("c") && t.contains("d"));
+ assert_eq!(t.take_forgotten(), 1);
+ assert_eq!(t.take_forgotten(), 0);
+ }
+
+ #[test]
+ fn a_key_that_stopped_counting_is_forgotten_before_a_live_one() {
+ let (mut t, t0) = (RateTable::new(3), Instant::now());
+ assert!(hit(&mut t, "old", t0));
+ assert!(hit(&mut t, "mid", t0 + Duration::from_secs(50)));
+ assert!(hit(&mut t, "new", t0 + Duration::from_secs(100)));
+ assert!(hit(&mut t, "fresh", t0 + Duration::from_secs(100)));
+ assert!(!t.contains("old") && t.contains("mid") && t.contains("new"));
+ }
+
+ #[test]
+ fn a_flood_of_new_keys_never_refuses_one() {
+ let (mut t, now) = (RateTable::new(100), Instant::now());
+ for i in 0..5000 {
+ assert!(hit(&mut t, &format!("flood{i}|challenge"), now), "flood key {i} was refused");
+ }
+ assert_eq!(t.len(), 100);
+ assert!(hit(&mut t, "honest|create", now));
+ }
+
+ #[test]
+ fn a_flooder_that_stays_active_keeps_its_limit() {
+ let (mut t, now) = (RateTable::new(3), Instant::now());
+ for _ in 0..10 {
+ assert!(hit(&mut t, "loud", now));
+ }
+ for i in 0..50 {
+ assert!(!hit(&mut t, "loud", now), "the flooder passed after {i} refusals");
+ assert!(hit(&mut t, &format!("quiet{i}"), now));
+ }
+ assert!(t.contains("loud"), "a refused request did not count as seen");
+ }
+
+ #[test]
+ fn the_recency_queue_stays_bounded_under_many_touches_of_few_keys() {
+ let (mut t, now) = (RateTable::new(50), Instant::now());
+ for i in 0..20_000 {
+ hit(&mut t, if i % 2 == 0 { "x" } else { "y" }, now);
+ }
+ assert!(t.recency.len() <= 2 * t.len() + SLACK + 1, "the queue held {} entries", t.recency.len());
+ }
+
+ #[test]
+ fn prune_forgets_the_idle_and_keeps_the_queue_and_table_one_to_one() {
+ let (mut t, t0) = (RateTable::new(10), Instant::now());
+ hit(&mut t, "idle", t0);
+ hit(&mut t, "busy", t0 + Duration::from_secs(100));
+ hit(&mut t, "busy", t0 + Duration::from_secs(101));
+ t.prune(t0 + Duration::from_secs(110), W);
+ assert!(!t.contains("idle") && t.contains("busy"));
+ assert_eq!(t.recency.len(), t.len());
+ }
+}
diff --git a/server/src/tls.rs b/server/src/tls.rs
index 819958fb6..57205df8a 100644
--- a/server/src/tls.rs
+++ b/server/src/tls.rs
@@ -23,6 +23,15 @@ 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; 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`.
+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`).
///
diff --git a/server/src/version_gate.rs b/server/src/version_gate.rs
index 320035475..77e33df08 100644
--- a/server/src/version_gate.rs
+++ b/server/src/version_gate.rs
@@ -33,6 +33,23 @@ pub fn game_shape_ok(game: &str) -> bool {
&& suffix.bytes().all(|b| b.is_ascii_lowercase())
}
+/// The running game build a host reads from its own install (the mod's `ue_wrap/core/game_build`):
+/// `-`, or `-nopak` when its pak could not be read;
+/// empty from a host that predates the field. A parsing concern like `game_shape_ok`: the browser compares
+/// it byte for byte and names it, so nothing else may reach that field.
+pub fn game_build_shape_ok(key: &str) -> bool {
+ if key.is_empty() {
+ return true;
+ }
+ let hex = |s: &str| s.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b));
+ match key.split_once('-') {
+ Some((stamp, rest)) => {
+ stamp.len() == 8 && hex(stamp) && (rest == "nopak" || (rest.len() == 16 && hex(rest)))
+ }
+ None => false,
+ }
+}
+
/// Pure gate half (env resolved by the caller so this is unit-testable).
///
/// SHAPE ONLY. THE MASTER DOES NOT ADJUDICATE WHICH BUILDS EXIST -- the proto ceiling
@@ -58,23 +75,69 @@ pub fn game_shape_ok(game: &str) -> bool {
///
/// What STAYS is `game_shape_ok`, because that is a PARSING concern (keep arbitrary
/// strings out of a field every browser renders), not a version-policy one.
-pub fn version_gate(game: &str, proto: i64) -> Result<(), String> {
+pub fn version_gate(game: &str, proto: i64, game_build: &str) -> Result<(), String> {
if !game.is_empty() && !game_shape_ok(game) {
return Err(format!("bad game version '{}'", game));
}
+ if !game_build_shape_ok(game_build) {
+ return Err("bad game build key".to_string());
+ }
let _ = proto;
Ok(())
}
-pub fn version_gate_env(game: &str, proto: i64) -> Result<(), String> {
+/// The first `kProtocolVersion` whose host signs its announce (the lobby proof): a create from a
+/// build at or above it must carry a valid proof, a create from an older build cannot sign and is
+/// listed unproved. A fact about the protocol, so a code constant; the maintainer's floor
+/// (`COOP_MIN_LISTED_PROTO`) is the config that retires the unproved tier.
+///
+/// This is the kProtocolVersion of the build that first carries the proof's client half. A server
+/// test reads `protocol.h` and fails when this exceeds the tree's own kProtocolVersion, so a value
+/// no build can reach (no build is then required to prove) cannot be committed.
+pub const MIN_PROVED_PROTO: i64 = 230;
+
+/// A create from this build cannot be listed without a valid proof.
+pub fn proof_required(proto: i64) -> bool {
+ proto >= MIN_PROVED_PROTO
+}
+
+/// A create from this build is below the maintainer's floor (`COOP_MIN_LISTED_PROTO`).
+pub fn below_floor(proto: i64, floor: i64) -> bool {
+ proto < floor
+}
+
+pub fn version_gate_env(game: &str, proto: i64, game_build: &str) -> Result<(), String> {
// No env left to resolve: the ceiling and the allowlist are retired. Kept as a named
// seam so the call site still reads as a gate and a future policy has an obvious home.
- version_gate(game, proto)
+ version_gate(game, proto, game_build)
}
#[cfg(test)]
mod tests {
- use super::{game_shape_ok, version_gate};
+ use super::{below_floor, game_build_shape_ok, game_shape_ok, proof_required, version_gate, MIN_PROVED_PROTO};
+
+ #[test]
+ fn a_build_that_signs_must_prove_and_the_floor_is_a_plain_bound() {
+ assert!(!proof_required(MIN_PROVED_PROTO - 1));
+ assert!(proof_required(MIN_PROVED_PROTO));
+ assert!(proof_required(65535));
+ assert!(!below_floor(0, 0), "the default floor refuses nothing");
+ assert!(!below_floor(230, 230));
+ assert!(below_floor(229, 230));
+ }
+
+ #[test]
+ fn the_proof_floor_is_a_protocol_the_tree_has_reached() {
+ let header = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/votv-coop/include/coop/net/protocol.h");
+ let text = std::fs::read_to_string(header).unwrap_or_else(|e| panic!("cannot read {header}: {e}"));
+ let line = text
+ .lines()
+ .find(|l| l.contains("kProtocolVersion =") && l.contains("uint16_t"))
+ .expect("protocol.h has no kProtocolVersion definition");
+ let digits: String = line.split('=').nth(1).expect("no value").chars().filter(char::is_ascii_digit).collect();
+ let version: i64 = digits.parse().expect("kProtocolVersion is not a number");
+ assert!(MIN_PROVED_PROTO <= version, "MIN_PROVED_PROTO {MIN_PROVED_PROTO} is above kProtocolVersion {version}");
+ }
#[test]
fn game_shape_accepts_real_votv_versions() {
@@ -97,23 +160,36 @@ mod tests {
// tester running a build the master has never heard of MUST be able to host.
// b148 against a "newest released b143" was the case the old ceiling refused,
// and refusing it denied our own testers on every proto bump.
- assert!(version_gate("0.9.0n", 148).is_ok());
- assert!(version_gate("0.9.0n", 9999).is_ok());
- assert!(version_gate("0.9.0n", 143).is_ok());
- assert!(version_gate("0.9.0n", 133).is_ok());
+ assert!(version_gate("0.9.0n", 148, "").is_ok());
+ assert!(version_gate("0.9.0n", 9999, "").is_ok());
+ assert!(version_gate("0.9.0n", 143, "").is_ok());
+ assert!(version_gate("0.9.0n", 133, "").is_ok());
}
#[test]
fn legacy_and_unadvertised_pass() {
- assert!(version_gate("", 0).is_ok()); // legacy pre-v122 host
- assert!(version_gate("0.9.0n", 0).is_ok()); // no build advertised
+ assert!(version_gate("", 0, "").is_ok()); // legacy pre-v122 host
+ assert!(version_gate("0.9.0n", 0, "").is_ok()); // no build advertised
+ }
+
+ #[test]
+ fn game_build_shape() {
+ for k in ["", "6a08597d-56065b874f3bfc68", "6aa6f055-0bc42acf66e7daaa", "6a08597d-nopak"] {
+ assert!(game_build_shape_ok(k), "{k} should pass");
+ }
+ for k in ["6a08597d", "6A08597D-56065b874f3bfc68", "6a08597d-56065b874f3bfc6", "6a08597-56065b874f3bfc68",
+ "6a08597d-56065b874f3bfc68x", "6a08597d-NOPAK", "-nopak", "6a08597d_nopak"] {
+ assert!(!game_build_shape_ok(k), "{k} should fail");
+ }
+ assert!(version_gate("0.9.0n", 222, "6a08597d-56065b874f3bfc68").is_ok());
+ assert!(version_gate("0.9.0n", 222, "lol").is_err());
}
#[test]
fn gate_still_refuses_bad_shape_with_any_proto() {
// SHAPE survives: this is a parsing concern (keep arbitrary strings out of a
// field every browser renders), not a version-policy one.
- assert!(version_gate("h4x0r edition", 133).is_err());
- assert!(version_gate("h4x0r edition", 9999).is_err());
+ assert!(version_gate("h4x0r edition", 133, "").is_err());
+ assert!(version_gate("h4x0r edition", 9999, "").is_err());
}
}
diff --git a/server/tests/conn_cost.rs b/server/tests/conn_cost.rs
new file mode 100644
index 000000000..0a4082901
--- /dev/null
+++ b/server/tests/conn_cost.rs
@@ -0,0 +1,179 @@
+//! 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 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};
+
+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");
+
+ // 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), 10_000);
+ 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), 10_000);
+ 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);
+
+ // 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"));
+ 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);
+ let ((), buffered) = held(|| {
+ let mut rd = &record[..];
+ while !rd.is_empty() {
+ if conn.read_tls(&mut rd).unwrap_or(0) == 0 {
+ break;
+ }
+ }
+ });
+ bound("tls server connection, a 16 KiB record pending", buffered, 40.0);
+ drop(conn);
+}
diff --git a/server/tests/vectors/lobby_proof.json b/server/tests/vectors/lobby_proof.json
new file mode 100644
index 000000000..f28046a9e
--- /dev/null
+++ b/server/tests/vectors/lobby_proof.json
@@ -0,0 +1,9 @@
+{
+ "tag": "multivoid-lobby-announce-v1",
+ "seed": "202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f",
+ "identity": "gen:29acbae141bccaf0b22e1a94d34d0bc7361e526d0bfe12c89794bc9322966dd7",
+ "nonce": "030a11181f262d343b424950575e656c737a81888f969da4abb2b9c0c7ced5dc",
+ "master_name": "master.multivoid.dev",
+ "blob_hex": "6d756c7469766f69642d6c6f6262792d616e6e6f756e63652d763167656e3a32396163626165313431626363616630623232653161393464333464306263373336316535323664306266653132633839373934626339333232393636646437303330613131313831663236326433343362343234393530353735653635366337333761383138383866393639646134616262326239633063376365643564636d61737465722e6d756c7469766f69642e646576",
+ "signature": "4d76c9db55d9a5fa53b81543babe2e896cdd907f4da66a25b83c8a895c87e3c3ccf8a31903b99389b08ad48975e0f8480813218f79b45c7f255a7aa4d78a1507"
+}
diff --git a/server/tests/vectors/master_names.json b/server/tests/vectors/master_names.json
new file mode 100644
index 000000000..d42fdf1c9
--- /dev/null
+++ b/server/tests/vectors/master_names.json
@@ -0,0 +1,12 @@
+[
+ {"url": "master.multivoid.dev:10443", "name": "master.multivoid.dev"},
+ {"url": "https://master.multivoid.dev:10443", "name": "master.multivoid.dev"},
+ {"url": "http://203.0.113.7:10443", "name": "203.0.113.7"},
+ {"url": "HTTPS://Master.Multivoid.DEV:10443", "name": "master.multivoid.dev"},
+ {"url": "https://master.multivoid.dev:10443/v1/lobbies", "name": "master.multivoid.dev"},
+ {"url": "https://master.multivoid.dev.:10443", "name": "master.multivoid.dev"},
+ {"url": "https://alice:pw@master.multivoid.dev:10443", "name": "master.multivoid.dev"},
+ {"url": "[::1]:10443", "name": "[::1]"},
+ {"url": "https://[2001:db8::1]:10443/", "name": "[2001:db8::1]"},
+ {"url": "https://b\u00fccher.example:10443", "name": ""}
+]
diff --git a/src/votv-coop/CMakeLists.txt b/src/votv-coop/CMakeLists.txt
index 9165d09b9..40208517b 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)
@@ -224,6 +225,10 @@ include(${CMAKE_CURRENT_SOURCE_DIR}/sources.cmake)
add_library(votv-coop SHARED ${VOTVCOOP_SOURCES})
target_include_directories(votv-coop PRIVATE include ${CMAKE_CURRENT_BINARY_DIR}/generated)
+# The fingerprint census compares the running game with the reference table of the game target
+# (docs/bytecode-fingerprint.md); a target with no generated table does not compile.
+target_compile_definitions(votv-coop PRIVATE
+ "VOTVCOOP_FINGERPRINT_TABLE=\"ue_wrap/facts/fingerprints_${VOTVCOOP_GAME_TARGET}.inc\"")
# The UE4SS mod-folder contract name (UE4SS_ARC WP-2 commit 3): the DLL ships
# as Mods/Multivoid/dlls/main.dll. The versioned multivoid--.dll
# OUTPUT_NAME retired WITH its one consumer, the xinput proxy's scan -- the
@@ -262,6 +267,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).
@@ -324,6 +347,10 @@ if (MSVC)
# backslash in a path literal compiled to a folder beside AppData\Local instead of under
# it, and the build stayed green.
target_compile_options(votv-coop PRIVATE /W4 /permissive- /EHa /utf-8 /we4129)
+ # /MP: cl compiles a project's sources in parallel, one process per core. MSBuild's own
+ # parallelism (`cmake --build --parallel`) runs PROJECTS side by side, and this target is one
+ # project of ~800 sources, which without /MP compile one after another on a single core.
+ target_compile_options(votv-coop PRIVATE /MP)
# /MAP emits main.map (symbol -> RVA) so a faulting IP captured by the
# game_thread Pump firewall (absorbed-fault localization) maps to a function
# name by grepping the linker map -- no IDA needed. The .map lands 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
diff --git a/src/votv-coop/include/coop/atomic_file/atomic_file.h b/src/votv-coop/include/coop/atomic_file/atomic_file.h
index decd50734..9b36b298a 100644
--- a/src/votv-coop/include/coop/atomic_file/atomic_file.h
+++ b/src/votv-coop/include/coop/atomic_file/atomic_file.h
@@ -41,6 +41,7 @@ std::filesystem::path TempPathFor(const std::filesystem::path& target);
// fails with ERROR_INVALID_PARAMETER; a process this one may not open is running.
bool ProcessRunning(unsigned long pid);
+// An empty target is refused first (failedAt == Step::Open, ERROR_PATH_NOT_FOUND), touching nothing.
// Deletes the target's leftovers (RemoveLeftovers over its folder and its own file name), then
// writes `bytes` to TempPathFor(target) (CreateFileW CREATE_ALWAYS, GENERIC_WRITE, no sharing,
// `security` as given), WriteFile in chunks of at most 1 MiB, FlushFileBuffers when ToDisk,
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/comms/chat_feed.h b/src/votv-coop/include/coop/comms/chat_feed.h
index 169a149b3..7fe05d026 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
};
@@ -98,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
@@ -108,14 +114,18 @@ 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
// 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);
// 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
@@ -133,14 +143,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/chat_rate.h b/src/votv-coop/include/coop/comms/chat_rate.h
new file mode 100644
index 000000000..b78a02160
--- /dev/null
+++ b/src/votv-coop/include/coop/comms/chat_rate.h
@@ -0,0 +1,38 @@
+// coop/comms/chat_rate.h -- how fast a client may speak, judged on the host: Source's chat flood guard as it ships.
+// A line passes when 0.66 s have gone since its sender last talked and a two-tier token bucket allows it. Every
+// attempt spends from the bucket, a line that passed starts the interval, and a refused line is dropped for good, with
+// no answer (reference/source-sdk-2013/src/game/server/player.cpp:8771-8818, client.cpp:843-862). MTA's server has no
+// chat rate, only a length cap (reference/mtasa-blue/Server/mods/deathmatch/Config.h:34) and a mute, both in its say
+// (reference/mtasa-blue/Server/mods/deathmatch/logic/CConsoleCommands.cpp:356-369), so Source is the precedent. One
+// state per slot, cleared with the occupant. The host's own lines are not counted: bounds apply to clients, where
+// Source's listen host is a player like any other. Game thread.
+
+#pragma once
+
+#include
+
+namespace coop::chat_rate {
+
+// One slot's budget; a value-initialised one is Source's fresh player (player.cpp:613-616).
+struct TalkBudget {
+ uint64_t lastAttemptMs = 0; // 0 = never: the first attempt's elapsed is the whole clock, a full tier 1
+ uint64_t lastTalkMs = 0; // 0 = never talked
+ float tier1 = 1.0f;
+ float tier2 = 10.0f;
+};
+
+// What one attempt meets: Interval when the 0.66 s refuses it (Source's first test), else Bucket when a tier does.
+enum class TalkVerdict : uint8_t { Talk, Interval, Bucket };
+
+// One attempt at `nowMs`, a steady clock's milliseconds, on `b`: Source's CanPlayerTalk, both tests evaluated, and on
+// Talk the slot has talked now (NotePlayerTalked). Pure.
+TalkVerdict Attempt(TalkBudget& b, uint64_t nowMs);
+
+// Attempt on `slot`'s own budget. Host, game thread.
+TalkVerdict AttemptLine(uint8_t slot, uint64_t nowMs);
+
+// The arithmetic on local budgets, each row from a stated state, worked from Source's formula in float. Logs
+// `chat_rate selftest: ALL PASS (N checks)` or one FAIL line per failing case.
+bool RunChatRateSelftest();
+
+} // namespace coop::chat_rate
diff --git a/src/votv-coop/include/coop/comms/chat_sync.h b/src/votv-coop/include/coop/comms/chat_sync.h
index aaa2fd2ac..07ebd25ba 100644
--- a/src/votv-coop/include/coop/comms/chat_sync.h
+++ b/src/votv-coop/include/coop/comms/chat_sync.h
@@ -16,6 +16,7 @@
#pragma once
+#include
#include
#include
#include
@@ -55,10 +56,14 @@ bool RunChatLineSelftest();
// Every OnReliable below runs on the game thread, in the event_feed drain.
void QueueSend(const std::string& utf8Text);
-// HOST receiver: a client's chat INTENT. Validates, commits, broadcasts. A client that
-// somehow receives one drops it -- nothing sends ChatMessage to a client any more.
-// Game thread.
-void OnReliable(const coop::net::ChatMessagePayload& payload, uint8_t senderPeerSlot);
+// HOST receiver: a client's chat INTENT, its raw bytes and the transport's slot. Judged first by the
+// sender's chat rate (coop/comms/chat_rate.h: a refused line is dropped for good, with no answer), then
+// its length, then validated, committed and broadcast. A client that somehow receives one drops it --
+// nothing sends ChatMessage to a client any more. Game thread.
+void OnReliable(const uint8_t* bytes, size_t len, int senderSlot);
+
+// Each tick, both roles: closes the receipt's refusal window when it has run its span. Game thread.
+void Tick();
// CLIENT receivers: the host's authored row, and the speaker binding that precedes it.
// Game thread.
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..faba3bc44 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,47 @@
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 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.h b/src/votv-coop/include/coop/config/config.h
index c45282c7d..3a060769c 100644
--- a/src/votv-coop/include/coop/config/config.h
+++ b/src/votv-coop/include/coop/config/config.h
@@ -1,9 +1,9 @@
-// coop/config/config.h -- the env and ini configuration readers. multivoid.ini lives next to the
-// mod DLL; the LAN test framework overrides through environment variables, since one DLL location
-// serves two instances and per-file configs would alias. Precedence: the session's value of a
-// server-scope row while a session runs (the session layer), then a value set while the game runs
-// (the runtime layer), then the environment variable (set by the test launcher), then the ini
-// value, then the row default.
+// coop/config/config.h -- the env and ini configuration readers. multivoid.ini lives in the mod's
+// data folder (ue_wrap::paths::ModDataDir); the LAN test framework overrides through environment
+// variables, since one DLL location serves two instances and per-file configs would alias.
+// Precedence: the session's value of a server-scope row while a session runs (the session layer),
+// then a value set while the game runs (the runtime layer), then the environment variable (set by
+// the test launcher), then the ini value, then the row default.
#pragma once
@@ -257,7 +257,7 @@ enum class ExampleGen : unsigned char {
SkippedUnreadable, // existing file present but unreadable -- no doomed swap
};
-// Generate multivoid.ini.example beside the DLL: every registry row as wrapped description
+// Generate multivoid.ini.example in the mod's data folder: every registry row as wrapped description
// prose, the generator-emitted allowed tokens, range and env twin, and a copyable commented
// key=default line, under bare section headers. Deterministic bytes (no timestamp; numeric
// emission in the C locale); compare first; the one atomic-swap primitive; fail-soft, since the
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 62ebf7bfd..96338330d 100644
--- a/src/votv-coop/include/coop/config/config_registry_rows.inc
+++ b/src/votv-coop/include/coop/config/config_registry_rows.inc
@@ -51,7 +51,7 @@ CFG_STRING(net_peer, "net.peer", "net", "127.0.0.1", "VOTVCOOP_NET_PEER", false,
"Test framework: the host address a client connects to at auto-start (lan topology).")
CFG_ROWFLAGS(net_peer, kRowAddress)
CFG_STRING(net_server, "net.server", "net", "", "VOTVCOOP_NET_SERVER", false,
- "The server this install hosts: the name of its folder under multivoid_servers beside the game. "
+ "The server this install hosts: the name of its folder under multivoid_servers in the mod's folder. "
"Set from your nickname the first time you host; empty means you have not hosted yet.")
CFG_INT(net_port, "net.port", "net", ::coop::net::kDefaultPort, 1, 65535, "VOTVCOOP_NET_PORT",
"UDP port a DIRECT host listens on: the lan topology, and a DIRECT game hosted from the menu.")
@@ -74,6 +74,31 @@ CFG_INT(net_fakelink_kbs, "net.fakelink_kbs", "net", 0, 0, 1048576, "VOTVCOOP_NE
"Simulated OUTBOUND link capacity, KB/s (GNS FakeRateLimit_Send: a process-global policer "
"that silently DROPS packets beyond the budget -- worst-case thin-uplink physics). 0 = off. "
"Testing knob for the overdrive drill; pair with net.sendrate_kbs above this value.")
+CFG_INT(net_fakelag_ms, "net.fakelag_ms", "net", 0, 0, 2000, "VOTVCOOP_NET_FAKELAG_MS",
+ "Simulated extra one-way delay on every OUTBOUND packet, ms (GNS FakePacketLag_Send, "
+ "process-global), so the round trip grows by this much when both peers set it. 0 = off. "
+ "Testing knob for the send-rate law on a long link.")
+CFG_INT(net_fakeloss_send_pct, "net.fakeloss_send_pct", "net", 0, 0, 100,
+ "VOTVCOOP_NET_FAKELOSS_SEND_PCT",
+ "Percent of OUTBOUND packets silently discarded (GNS FakePacketLoss_Send, process-global). "
+ "0 = off. Testing knob for the send-rate law on a lossy link.")
+CFG_INT(net_fakeloss_recv_pct, "net.fakeloss_recv_pct", "net", 0, 0, 100,
+ "VOTVCOOP_NET_FAKELOSS_RECV_PCT",
+ "Percent of RECEIVED packets silently discarded (GNS FakePacketLoss_Recv, process-global). "
+ "0 = off. Testing knob for the send-rate law on a lossy link.")
+CFG_INT(net_fakejitter_ms, "net.fakejitter_ms", "net", 0, 0, 1000, "VOTVCOOP_NET_FAKEJITTER_MS",
+ "Mean extra random delay on every OUTBOUND packet, ms (GNS FakePacketJitter_Send_Avg, "
+ "exponentially distributed, applied to every packet; packets are clumped, not reordered). "
+ "0 = off. Testing knob for the send-rate law on a link whose round trip wanders.")
+CFG_STRING(net_fakelink_steps, "net.fakelink_steps", "net", "", "VOTVCOOP_NET_FAKELINK_STEPS", false,
+ "Changes the fake-link knobs above DURING a run, timed from the world transfer's own events: "
+ "steps separated by ';', each +:=[&=