Wolfenstein Modloader Buy me a coffeeKaffee spendieren

In plain wordsIn einfachen Worten

This report describes a new feature of the mod loader: a small local web server that shows maps of Wolfenstein II: The New Colossus in 3D in a full-screen browser window, with geometry, textures and collision. It is started from a new button in the loader or from the command line. Automatic tests passed 97 of 97 checks on three maps, and 8 deliberately built-in bugs were each caught. The server only accepts connections from the same computer; the game itself was only read, never started.

  • 97 of 97 checks passed on three maps, including the large submarine map (5,533 polygons, 1,328 textures).
  • 8 of 8 deliberately inserted bugs were detected by the matching test.
  • The server listens only on 127.0.0.1; 36 of 36 path-escape attempts returned 'not found'.
  • Clicking the button blocks the loader for only about 0.001 seconds.
  • That Edge really opens a full-screen app window was not machine-tested.

Dieser Bericht beschreibt eine neue Funktion des Mod-Loaders: einen kleinen lokalen Webserver, der Karten von Wolfenstein II: The New Colossus in 3D in einem Vollbild-Browserfenster zeigt, mit Geometrie, Texturen und Kollision. Gestartet wird er über einen neuen Button im Loader oder über die Kommandozeile. Automatische Tests bestanden 97 von 97 Prüfungen auf drei Karten, und 8 absichtlich eingebaute Fehler wurden jeweils erkannt. Der Server nimmt nur Verbindungen vom eigenen Rechner an; das Spiel wurde nur gelesen, nie gestartet.

  • 97 von 97 Prüfungen bestanden auf drei Karten, darunter die große U-Boot-Karte (5.533 Polygone, 1.328 Texturen).
  • 8 von 8 absichtlich eingebauten Fehlern wurden vom passenden Test erkannt.
  • Der Server lauscht nur auf 127.0.0.1; 36 von 36 Ausbruchsversuche über Pfade ergaben 'nicht gefunden'.
  • Der Klick auf den Button blockiert den Loader nur etwa 0,001 Sekunden.
  • Dass Edge wirklich ein Vollbild-App-Fenster öffnet, wurde nicht automatisch getestet.

Technical detailsTechnische Details

Der technische Teil ist auf Englisch – so, wie er im Wiki steht. Unterstrichene Begriffe haben eine Erklärung auf Deutsch.

Worker serve in workflow loader-mapview, 2026-09-24 (second run at 3 PM: all remeasured, control samples moved to Verifier, approval check renewed). Files: wolfsdk/mapserver.py (new), wolfsdk/cli.py (+25 lines, command maps), wolfsdk/gui.py (+26/−2, button in Assets tab), tools/verify_mapview.py (new, with --control-samples). The frontend (wolfsdk/mapview/) originates from the parallel worker; this server serves it exclusively.

Results

  • python tools/verify_mapview.py → 97/97 checks OK, rc=0, 36 s (with warm map cache). Checked c2v1, c12p1 (campaign), and the U-Boot.
  • python tools/verify_mapview.py --control-samples → 9/9, rc=0, 76 s. The control run without mutation remains green (0 red). Each of the 8 built-in bugs colors exactly the check it is supposed to catch red (table below). Control samples run in separate processes each and are repeatable at any time.
  • The Part-1 GUI is unchanged. tools/verify_gui.py ran twice: once on a copy of the state before the change (gui.py and cli.py from HEAD, everything else as now, in a temp folder) and once on the current state. Both outputs are byte-identical (diff empty), 31 checks OK each, "All good." and rc=0.
  • Only 127.0.0.1 listens (netstat per PID: 1/1 Socket). LAN addresses refuse connection (4/4), ::1 as well.
  • Path breakout: 36/36 attack paths yield 404, the secret next to the folder never emerges. Against the real Viewer folder, 6/6 hold.
  • The click in the Loader costs the Tk-Thread 0.001 s (Socket bind). The first import of mapserver with backends costs an additional 0.03 s (3/3 measurements).

Structure

Loader (tkinter) --Click--> mapserver.start(tnc_root) -> http://127.0.0.1:<free>/
                              mapserver.open_app(url)    -> msedge --app=<url> --start-fullscreen
                                                            --user-data-dir=%LOCALAPPDATA%\wolfsdk\edge-profile
python -m wolfsdk maps [id] [--no-browser]               -> same, blocks until Ctrl+C
Endpoint Source Delivery
GET /, /<file> wolfsdk/mapview/ File, Content-Type from fixed table (not mimetypes: Windows Registry makes .js sometimes text/plain)
GET /api/maps mapcatalog.maps JSON [{id,title,group,entities_bytes}]. Maps without .entities are missing (today: c5v1, the Exec carrier from dlc_5). Cached until a Spec or Archive changes (stats via base/*.resources, dlc/dlc_*/base/*, Spec); second fetch takes 0.00 s
GET /api/map/<id>/scene.json mapgeo.scene Cache file, streamed. Also opens the map mount for /api/tex
GET /api/map/<id>/geometry.bin mapgeo.scene Cache file, streamed in 1-MB blocks
GET /api/map/<id>/collision.json mapcoll.collision Cache file, streamed
GET /api/tex/<id>[?max=N] maptex.texture_block WTX1 + one Mip, Cache-Control: max-age=86400 (IDs are content-addressed)
GET /api/tex/<id>.png[?max=N] maptex.texture_png PNG same Mip: Server gives both calls same max (maptex has 512 or 1024 as default)
  • A missing max defaults to 1024. max is clamped to the range 16..4096 and rounded down to the nearest power of two so that no single value generates its own cache file in maptex. max=abc yields 400.
  • Error codes: KeyError (unknown map or texture) and TncImageError (no Mip) return 404. Everything else returns 500 with the German message as text/plain, e.g., MapGeoError: game/dlc/c05/c5v1: keine .entities im Container. A request never crashes the server.
  • The server speaks HTTP/1.1 with Keep-alive; every response includes a Content-Length. Ranges are not supported; responses are always 200 with full content. Only GET and HEAD are allowed; POST returns 501.
  • A texture ID does not carry a map. /api/tex therefore queries the last opened mounts (at most 3, newest first). A mount is opened by a scene.json request. Displaced mounts are not explicitly closed: running requests still hold them, and CPython closes them with the last reference. Verified: after three additional maps, the displaced mount and its archive file are released (weakref), even though maptex had already created state for it.
  • The race condition in mapcatalog's index is bypassed, not fixed. The server builds a mount's index using find("", "") in the opening thread before another thread sees the mount. Mount._idx does not build the index in a thread-safe manner (see mapcatalog finding). With 8 threads, all 464/187/1328 textures were retrieved without false positives.
  • Security:
    • Binding is restricted to 127.0.0.1, port 0 (the system selects a free one). Windows sockets receive SO_EXCLUSIVEADDRUSE and allow_reuse_address = False, preventing anyone from binding the same port again.
    • The Host header must be 127.0.0.1:<port> or localhost:<port>; otherwise, a 403 is returned (protection against DNS rebinding).
    • Static paths: :, \, NUL, and empty strings, as well as . or .. segments, are rejected. The resolved path must then lie under mapview/ and be a file.
    • self.path is split manually, not via urlsplit: this prevents interpreting a leading // as a hostname.
  • Lifespan: one server per process (start() returns the same URL on subsequent calls). It runs as a daemon thread, with atexit(stop). Requests are handled by daemon threads so that an ongoing 100-MB download does not block the loader termination. log_message is silent because stderr is None under pythonw.
  • HTTPServer.server_bind is bypassed. It calls socket.getfqdn, performing a reverse DNS lookup that can take seconds on Windows machines.
  • Browser launch:
    • Edge launches from %ProgramFiles(x86)%, %ProgramFiles%, and %LOCALAPPDATA%, followed by Chrome at the same locations (with its own chrome-profile), finally falling back to webbrowser.open. If Popen fails with OSError, webbrowser is still attempted.
    • By using its own profile, a standalone full-screen app window opens instead of a tab in the user's browser.
  • GUI:
    • The Assets tab receives a header stating "Wolfenstein II: every map with geometry, textures, and collision" and an accent button on the right labeled "Maps in 3D (Full Screen)". The asset browser sits unchanged below it (screenshot verified).
    • The button launches the TNC installation even if Part 1 is currently selected. If it is missing, a message appears and no server starts.
    • The click binds only the socket (measured at 0.001 s); handling and browser launch occur in background threads; the status line displays the URL.
  • CLI:
    • python -m wolfsdk maps opens the map selection.
    • maps <id> opens /?map=<id>, the URL parameter of the frontend.
    • --no-browser outputs only the address.
    • An unknown ID terminates with "Unknown map: …" and rc=1.

Numbers per Map (Verifier Run, warm cache)

Map scene.json geometry.bin collision.json Polygons Instances Textures WTX1 Sample = maptex PNG = same Mip
game/dlc/c02/c2v1 2.4 MB, 0.3 s* 16.0 MB, 0.1 s 5.3 MB 907/907 7741/7741 464/464 in 0.5 s 20/20 2/2 (16², 1024²)
game/wolf/c12/c12p1 0.5 MB, 0.2 s* 7.8 MB 1.1 MB 294/294 1384/1384 187/187 in 0.4 s 20/20 2/2
game/wolf/submarine/submarine 13.6 MB, 0.4 s* 87.9 MB, 0.3 s 37.1 MB 5533/5533 46736/46736 1328/1328 in 1.5 s 20/20 2/2

* Includes opening the Mount.

Additionally, for each of the three maps:

  • The delivered bytes are identical to the cache files (comparison and sha1), and the Content-Length matches.
  • Each polygon resides in geometry.bin (UV data directly follows position data; indices directly follow UV data). The file is contiguous and ends exactly at the last section. Every index is smaller than vcount.
  • Each collision hull points to an entity in scene.json (7760/7760, 1549/1549, 46041/46041).
  • Each Albedo is located in textures[] (473/473, 190/190, 1345/1345).
  • ?max=256 yields a maximum of 256 px (5/5).

Counter-tests (--gegenproben)

Each line represents its own process: verify_mapview.py --mutation <name> builds the error via Monkeypatch only in this specific process and checks only the responsible sections. The file mapserver.py remains untouched.

Mutation Built-in Error Expected (Red) Result
none Control, all sections of mutations nothing 0 red, rc 0
path static paths without bounding "Escape attempts → 404" red, plus 4 more
host every Host-Header allowed "foreign Host-Header → 403" red
list /api/maps unfiltered (c5v1 and entities key present) "every entry exactly …" red, plus count
png PNG with maptex-standard-max instead of equal max "PNG: same Mip …" red
short File 1 byte shorter than Content-Length "check_map without exception" (IncompleteRead) red
fullscreen Edge without --start-fullscreen "Edge found: …" red
blocked Button waits 1 s in Tk-Thread "Click does not block Tk" red
open no Mount is ever evicted (MOUNTS = 99) "evicted Mount is freed" red

Additional fixed counter-tests:

  • The netstat output must recognize 0.0.0.0 and ::.
  • Connection must succeed via 127.0.0.1.
  • The same secret file is delivered as soon as it exists in the directory.
  • Without TNC installation, a message appears and no server starts.

Binding to 0.0.0.0 is not run as a mutation: This would briefly expose the server to the LAN and could trigger a firewall dialog. This direction is covered by the netstat counter-test and the LAN-refusal test.

Limits

  • c2v1 is today the user's copy. The installation has dlc/dlc_0, whose spec also refers to game/dlc/c02/c2v1. mapcatalog sorts dlc_0 before dlc_2, so /api/maps returns c2v1 in the "Own" group (2 × Own, 25 Campaign, 12 DLC, 1 Other). This is a priority question from mapcatalog; the server only forwards the catalog. The verified c2v1 numbers (464 textures, 907 surfaces) are those of the dlc_0 version.
  • /api/tex knows only textures of the last up to 3 maps opened via scene.json. After a server restart, there are 404s until the frontend reloads scene.json. It does so on map switch and on reload anyway.
  • Mounts are cached by map ID. If Steam updates the game while the server is running, open mounts remain old. The scene and collision rebuild the map itself (keys via size+mtime); for textures only a loader restart helps.
  • On Windows, os.replace fails in a mapgeo/mapcoll rebuild as long as the server is streaming the same file. A new build only occurs after a game update or with a damaged cache. The request then gets 500; the next fetch succeeds.
  • There are neither ETag nor Range. scene.json, geometry.bin, and collision.json are fully transferred on every load. Locally this costs 0.1–0.4 s for 88 MB.
  • Opening a mount is globally serialized (ponytail: comment, 1–2 s per map). Texture requests from other already-open maps do not wait for it.
  • Errors from mapcoll on broken user .entities (AttributeError instead of CmError, see mapcoll finding) come as 500 with a message and do not bring the server down.
  • Inherited weaknesses of the backends that the server only forwards:
    • Running Loaders and python -m wolfsdk maps simultaneously building the same map can cause mapgeo to stumble over fixed .tmp names (mapgeo finding). The request gets 500; the next fetch hits the cache.
    • mapcoll holds its lock even on cache lookups. While another map builds, collision.json of a finished map waits up to about 13 s.
    • A broken user archive in dlc_* causes mapcatalog.maps() to fail; then /api/maps returns 500 and the selection remains empty.
  • Browser startup is stubbed in the Verifier; no window opens. That Edge really shows a fullscreen app window with these arguments is documented Chromium behavior and was not machine-tested. The screenshot check of the frontend runs on the frontend-worker and the Verifiers (headless Edge).
  • The Verifier reads cache files under %LOCALAPPDATA%\wolfsdk\mapcache. If they are missing, the first fetch builds them (U-Boot about 6 s geometry and 14 s collision).
  • The game directory was only read and the game was never started. Not touched: browser.py, tncview.py, tncimage.py, mapcatalog, mapgeo, maptex, mapcoll, and wolfsdk/mapview/.

Recheck

python tools/verify_mapview.py            # 97 checks, rc 0
python tools/verify_mapview.py --gegenproben   # Control + 8 mutations, rc 0
python tools/verify_gui.py                # rc 0, output as before the change
python -m wolfsdk maps --no-browser       # Address, then e.g. /api/maps in browser
python -m wolfsdk maps game/wolf/c12/c12p1