initial commit: llamamod 0.2.0 – reverse-engineered level formats (wad, map, trak, text, stpc), python tools and notes, java mod manager (iso install, mod pipeline with exe patching, save editor, wine/windows launcher) with mods (test world, debug wireframe, mirror, randomizer, fog, cheats, intro skip, practice, free camera, boot to level, level projects) and the 3d level editor (geometry, objects, cross-world import, environment, textures, play-test, manual)

This commit is contained in:
MineTec committed 2026-11-07 08:15:40 +01:00
commit b36a366240
826 files changed
+105304

No files matched your search

+720
View File
@@ -0,0 +1,720 @@
#!/usr/bin/env python3
"""apptest - run the game through the LlamaMod app, invisibly and muted, for testing mods.
Every session NAME has its own LlamaMod data folder, X display (Xvfb), private PulseAudio
daemon (null sink only) and Wine prefix, so several agents can test in parallel:
<root>/<NAME>/data LLAMAMOD_DATA (config.json, original/ (hard links), game/, cd-active/, wine/)
<root>/<NAME>/harness state.json, pulse/ (socket, rt, home), logs, xvfb.log
root = $APPTEST_ROOT or /home/elias/.claude/jobs/90c6eed7/tmp
Commands (NAME first; --display only for 'start'/'run', remembered afterwards):
apptest.py setup NAME install from the ISO (shared hard-linked template)
apptest.py apply NAME [ids] [--opt id.k=v]... apply mods (none = original) via the app CLI
apptest.py unlock NAME SLOT WORLD CHAPTER unlock up to WORLD/CHAPTER, map cursor there
apptest.py start NAME --display N Xvfb :N + private pulse + game (app --launch)
apptest.py tolevel NAME [--world W] main menu -> slot 3 -> map -> level -> gameplay
apptest.py keys NAME Up:1.5 x sleep=1 a+x:0.5 shot:name ... key sequence (hold s, default 0.2)
apptest.py shot NAME out.png [--small] screenshot (1920x1080 PNG, --small = 960x540 JPEG)
apptest.py ocr NAME [WxH+X+Y] German OCR of the screen (or a crop)
apptest.py status NAME game/window/crash/audio state (exit 3 = crashed)
apptest.py stop NAME kill game (wineserver -k), Xvfb and pulse of NAME
apptest.py run NAME --display N --mods a,b [--opt id.k=v]... --level W C [--seq "..."] --out DIR [--keep]
all in one; exit 0 ok, 3 crash, 4 timeout, 2 usage
Options of every command: --root DIR, --jar FILE (default: newest build/libs/*-all.jar),
--iso FILE (default ~/Downloads/GROOVE_DK.iso). Python: `from apptest import Session`.
Environment: APPTEST_CP=<extra classpath> + APPTEST_JAVA_OPTS="-Dllamamod.extraMods=<class>" test
mods that are not registered yet (e.g. APPTEST_CP=build/classes/java/test).
Safety: never uses display :0, never touches ~/.wine-lama, kills only PIDs it started itself
(checked against /proc/PID/cmdline) and the wineserver of its own prefix.
"""
import fcntl
import json
import os
import shutil
import signal
import subprocess
import sys
import time
HOME = os.path.expanduser("~")
PROJECT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
DEFAULT_ROOT = os.environ.get("APPTEST_ROOT", "/home/elias/.claude/jobs/90c6eed7/tmp")
DEFAULT_ISO = os.path.join(HOME, "Downloads", "GROOVE_DK.iso")
TEMPLATE = "_llamamod-template"
SCREEN = (1920, 1080)
FULL = "%dx%d+0+0" % SCREEN
SLOT_CROP = "1600x280+160+740" # save-slot text "Gespeichertes Spiel N / <level> Kapitel n"
MENU_CROP = "700x300+1150+650" # main menu items (bottom right)
class Crash(Exception):
pass
class Timeout(Exception):
pass
def log(*a):
print("[apptest]", *a, flush=True)
def find_java():
for j in (os.environ.get("JAVA"), os.path.join(HOME, ".jdks", "openjdk-25", "bin", "java")):
if j and os.access(j, os.X_OK):
return j
return shutil.which("java") or "java"
def find_jar():
if os.environ.get("APPTEST_JAR"):
return os.environ["APPTEST_JAR"]
d = os.path.join(PROJECT, "build", "libs")
jars = [os.path.join(d, f) for f in os.listdir(d) if f.endswith("-all.jar")] if os.path.isdir(d) else []
if not jars:
raise SystemExit("no fat jar - run ./gradlew fatJar in %s" % PROJECT)
return max(jars, key=os.path.getmtime)
def fuzzy_in(word, text, ratio=0.75):
import difflib
if word in text:
return True
toks = text.split()
n = len(word.split())
for i in range(len(toks) - n + 1):
if difflib.SequenceMatcher(None, word, " ".join(toks[i:i + n])).ratio() >= ratio:
return True
return False
def _cmdline(pid):
try:
return open("/proc/%d/cmdline" % pid, "rb").read().split(b"\0")
except OSError:
return []
def _environ(pid):
try:
return open("/proc/%d/environ" % pid, "rb").read().split(b"\0")
except OSError:
return []
def _kill_own(pid, expect):
"""Kill pid only if its executable name contains `expect` (guards against reused PIDs)."""
if not pid:
return
cmd = _cmdline(pid)
if not cmd or expect.encode() not in os.path.basename(cmd[0]):
return
try:
os.kill(pid, signal.SIGTERM)
for _ in range(30):
time.sleep(0.1)
if not _cmdline(pid):
return
os.kill(pid, signal.SIGKILL)
except ProcessLookupError:
pass
class Session:
def __init__(self, name, display=None, root=DEFAULT_ROOT, jar=None, iso=DEFAULT_ISO):
if not name or "/" in name or name.startswith("_"):
raise SystemExit("bad session name %r" % name)
self.name = name
self.root = root
self.dir = os.path.join(root, name)
self.data = os.path.join(self.dir, "data")
self.hdir = os.path.join(self.dir, "harness")
self.pulse_dir = os.path.join(self.hdir, "pulse")
self.state_file = os.path.join(self.hdir, "state.json")
self.jar = jar
self.iso = iso
os.makedirs(self.hdir, exist_ok=True)
self.state = {}
if os.path.exists(self.state_file):
self.state = json.load(open(self.state_file))
if display is not None:
d = ":%s" % str(display).lstrip(":")
if self.state.get("display") not in (None, d) and self.state.get("xvfb_pid") \
and _cmdline(self.state["xvfb_pid"]):
raise SystemExit("session %s already runs on %s" % (name, self.state["display"]))
self.state["display"] = d
self._save()
self.display = self.state.get("display")
if self.display in (":0", "0"):
raise SystemExit("refusing display :0")
def _save(self):
json.dump(self.state, open(self.state_file, "w"), indent=1)
# ------------------------------------------------------------ environment
@property
def prefix(self):
return os.path.join(self.data, "wine")
@property
def pulse_server(self):
return "unix:" + os.path.join(self.pulse_dir, "native")
def env(self):
e = dict(os.environ)
e["LLAMAMOD_DATA"] = self.data
e.pop("DBUS_SESSION_BUS_ADDRESS", None)
if self.display:
e["DISPLAY"] = self.display
e["PULSE_SERVER"] = self.pulse_server
e.pop("PULSE_SINK", None)
return e
def app(self, *args, check=True, timeout=1800):
"""Runs the LlamaMod CLI (fat jar) for this data folder."""
jar = self.jar or find_jar()
cmd = [find_java(), "-Djava.awt.headless=true"]
if self.display:
cmd.append("-Dllamamod.gameDisplay=" + self.display)
cmd += os.environ.get("APPTEST_JAVA_OPTS", "").split()
if os.environ.get("APPTEST_CP"):
cmd += ["-cp", jar + os.pathsep + os.environ["APPTEST_CP"], "com.eliasmueller.llamamod.Main"]
else:
cmd += ["-jar", jar]
cmd += list(args)
r = subprocess.run(cmd, env=self.env(), stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
text=True, timeout=timeout, start_new_session=True)
if check and r.returncode != 0:
raise SystemExit("llamamod %s failed (%d):\n%s" % (" ".join(args), r.returncode, r.stdout))
return r.stdout
# ------------------------------------------------------------ setup
def setup(self):
"""Installs the game into this session's data folder (once)."""
cfgp = os.path.join(self.data, "config.json")
if os.path.exists(cfgp) and json.load(open(cfgp)).get("layout", 0) >= 1:
self._config_defaults()
return
tdata = os.path.join(self.root, TEMPLATE, "data")
os.makedirs(os.path.dirname(tdata), exist_ok=True)
with open(os.path.join(self.root, TEMPLATE + ".lock"), "w") as lk:
fcntl.flock(lk, fcntl.LOCK_EX) # parallel agents: install the template only once
tcfg = os.path.join(tdata, "config.json")
if not (os.path.exists(tcfg) and json.load(open(tcfg)).get("layout", 0) >= 1):
if not os.path.exists(self.iso):
raise SystemExit("ISO not found: %s" % self.iso)
log("installing template from %s (once) ..." % self.iso)
r = subprocess.run([find_java(), "-Djava.awt.headless=true", "-jar", self.jar or find_jar(),
"--install", self.iso], env=dict(os.environ, LLAMAMOD_DATA=tdata),
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
if r.returncode:
raise SystemExit("template install failed:\n" + r.stdout)
os.makedirs(self.data, exist_ok=True)
if not os.path.isdir(os.path.join(self.data, "original")):
# hard links: the originals are never written (mods write to cache/, game/ is a copy)
subprocess.run(["cp", "-al", os.path.join(tdata, "original"), os.path.join(self.data, "original")],
check=True)
log("setting up %s ..." % self.data)
self.app("--install", self.iso)
self._config_defaults()
def _config_defaults(self):
cfgp = os.path.join(self.data, "config.json")
c = json.load(open(cfgp))
c.update(screenW=SCREEN[0], screenH=SCREEN[1], virtualDesktop=True)
json.dump(c, open(cfgp, "w"), indent=2)
def apply(self, mods=(), opts=()):
"""mods: ids; opts: 'id.key=value' strings. Returns the CLI output."""
args = ["--apply"]
if mods:
args.append(",".join(mods))
for o in opts:
args += ["--opt", o]
out = self.app(*args)
log(out.strip().splitlines()[-1] if out.strip() else "applied")
return out
def unlock(self, slot, world, chapter):
return self.app("--unlock", str(slot), str(world), str(chapter), "--cursor")
# ------------------------------------------------------------ processes
def game_pids(self, what=b"groove.exe"):
want = ("WINEPREFIX=" + self.prefix).encode()
out = []
for p in os.listdir("/proc"):
if not p.isdigit():
continue
cmd = b" ".join(_cmdline(int(p))).lower()
if what in cmd and want in _environ(int(p)):
out.append(int(p))
return out
def _start_xvfb(self):
if self.state.get("xvfb_pid") and _cmdline(self.state["xvfb_pid"]):
return
n = self.display.lstrip(":")
if os.path.exists("/tmp/.X%s-lock" % n) or os.path.exists("/tmp/.X11-unix/X%s" % n):
raise SystemExit("display %s is already in use by someone else - pick another number" % self.display)
xl = open(os.path.join(self.hdir, "xvfb.log"), "w")
p = subprocess.Popen(["Xvfb", self.display, "-screen", "0", "%dx%dx24" % SCREEN, "-nolisten", "tcp",
"+extension", "GLX"], stdout=xl, stderr=xl, start_new_session=True)
self.state["xvfb_pid"] = p.pid
self._save()
for _ in range(100):
if subprocess.run(["xdpyinfo"], env=self.env(), stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL).returncode == 0:
return
time.sleep(0.1)
raise SystemExit("Xvfb did not start (see harness/xvfb.log)")
def _start_pulse(self):
if self.state.get("pulse_pid") and _cmdline(self.state["pulse_pid"]):
return
d = self.pulse_dir
for sub in ("rt", "home"):
os.makedirs(os.path.join(d, sub), exist_ok=True)
os.chmod(os.path.join(d, "rt"), 0o700)
open(os.path.join(d, "home", ".pulse-cookie"), "a").close()
sock = os.path.join(d, "native")
if os.path.exists(sock):
os.remove(sock)
cfg = os.path.join(d, "default.pa")
open(cfg, "w").write("load-module module-null-sink sink_name=mute\nset-default-sink mute\n"
"load-module module-native-protocol-unix socket=%s auth-anonymous=1\n" % sock)
env = {k: v for k, v in os.environ.items() if k != "DBUS_SESSION_BUS_ADDRESS"}
env.update(DBUS_SESSION_BUS_ADDRESS="unix:path=/nonexistent", XDG_RUNTIME_DIR=os.path.join(d, "rt"),
HOME=os.path.join(d, "home"))
pl = open(os.path.join(self.hdir, "pulse.log"), "w")
p = subprocess.Popen(["pulseaudio", "-n", "-F", cfg, "--daemonize=no", "--exit-idle-time=-1",
"--use-pid-file=no"], env=env, stdout=pl, stderr=pl, start_new_session=True)
self.state["pulse_pid"] = p.pid
self._save()
for _ in range(100):
if os.path.exists(sock) and self.pactl("info").returncode == 0:
return
time.sleep(0.1)
raise SystemExit("private pulseaudio did not start (see harness/pulse.log)")
def pactl(self, *args):
return subprocess.run(["pactl", "-s", self.pulse_server] + list(args), stdout=subprocess.PIPE,
stderr=subprocess.PIPE, text=True,
env={k: v for k, v in os.environ.items() if not k.startswith("PULSE")})
def audio_clients(self):
"""application.name of the sink inputs on the private server (the game: "groove.exe")."""
r = self.pactl("list", "sink-inputs")
return [l.split("=", 1)[1].strip().strip('"') for l in r.stdout.splitlines()
if l.strip().startswith("application.name")]
def start(self, timeout=240):
if not self.display:
raise SystemExit("start needs --display N")
if self.game_pids():
log("game already running")
return
self.setup()
self._start_xvfb()
self._start_pulse()
log("launching on %s (first start creates the Wine prefix, ~1 min) ..." % self.display)
out = self.app("--launch")
log(out.strip().splitlines()[-1] if out.strip() else "launched")
t0 = time.time()
while time.time() - t0 < timeout:
if self.find_window() and self.game_pids():
log("window after %.0f s" % (time.time() - t0))
break
time.sleep(1)
else:
raise Timeout("no game window after %d s (see data/logs/game.log)" % timeout)
for _ in range(30):
c = self.crashed()
if c:
raise Crash(c)
if "groove.exe" in " ".join(self.audio_clients()).lower():
log("audio: groove.exe plays into the private null sink")
break
time.sleep(1)
else:
log("WARNING: no groove.exe sink input on the private pulse server yet")
def stop(self):
if self.game_pids(b"") or os.path.isdir(self.prefix):
env = dict(self.env(), WINEPREFIX=self.prefix)
subprocess.run(["wineserver", "-k"], env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
time.sleep(1)
for pid in self.game_pids(b""):
try:
os.kill(pid, signal.SIGKILL)
except ProcessLookupError:
pass
_kill_own(self.state.pop("pulse_pid", None), "pulseaudio")
_kill_own(self.state.pop("xvfb_pid", None), "Xvfb")
self._save()
log("stopped")
# ------------------------------------------------------------ X11
def _x(self, *args):
assert self.display and self.display != ":0"
return subprocess.run(["xdotool"] + list(args), env=self.env(), stdout=subprocess.PIPE,
stderr=subprocess.PIPE, text=True)
def find_window(self):
for pat in ("Wine desktop", "^LlamaMod", "^Groove$"):
ids = self._x("search", "--name", pat).stdout.split()
if ids:
return ids[-1]
return None
def _focus(self):
wid = self.find_window()
if not wid:
raise Crash("game window gone")
self._x("windowfocus", "--sync", wid)
def key(self, k, hold=0.2):
"""Press keys (xdotool names, 'a+b' together) for `hold` seconds; DirectInput misses <0.1 s."""
ks = k.split("+")
self._focus()
for x in ks:
self._x("keydown", x)
time.sleep(float(hold))
for x in reversed(ks):
self._x("keyup", x)
def keys(self, seq, gap=0.3, outdir=None):
"""Tokens: KEY[:hold], a+b[:hold], sleep=S, shot:NAME (needs outdir), dialog."""
shots = []
for item in seq.split() if isinstance(seq, str) else seq:
c = self.crashed()
if c:
raise Crash(c)
if item.startswith("sleep="):
time.sleep(float(item[6:]))
elif item.startswith("shot:"):
f = os.path.join(outdir or self.hdir, "seq_%02d_%s.jpg" % (len(shots), item[5:]))
shots.append(self.shot(f, small=True))
elif item == "dialog":
n = 0
while self.classify() == "letterbox" and n < 12:
self.key("x")
time.sleep(1.0)
n += 1
else:
k, _, h = item.partition(":")
self.key(k, float(h) if h else 0.2)
time.sleep(gap)
return shots
def shot(self, path, small=False):
os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
extra = ["-resize", "50%", "-quality", "80"] if small else []
r = subprocess.run(["import", "-display", self.display, "-window", "root", "-crop", FULL, "+repage"]
+ extra + [path], env=self.env(), stderr=subprocess.PIPE, text=True)
if r.returncode:
raise SystemExit("import: " + r.stderr)
return path
def _grab(self, crop, fmt, resize=None):
cmd = ["import", "-display", self.display, "-window", "root", "-crop", crop, "+repage"]
if resize:
cmd += ["-resize", resize]
return subprocess.run(cmd + ["-depth", "8", fmt + ":-"], stdout=subprocess.PIPE, env=self.env()).stdout
def ocr(self, crop=FULL, threshold=None, psm=11, scale="50%"):
"""Lower-case German OCR text; threshold (%) keeps only bright text (selected menu item)."""
raw = self._grab(crop, "ppm")
filt = ["-threshold", "%d%%" % threshold] if threshold else []
img = subprocess.run(["convert", "ppm:-", "-colorspace", "Gray"] + filt + ["-negate", "-resize", scale,
"pgm:-"],
input=raw, stdout=subprocess.PIPE).stdout
r = subprocess.run(["tesseract", "-", "-", "-l", "deu", "--psm", str(psm)], input=img,
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
return " ".join(r.stdout.decode("utf-8", "replace").lower().split())
def slot_text(self):
return self.ocr(SLOT_CROP, threshold=70, psm=6) + " | " + self.ocr(SLOT_CROP, threshold=82, psm=6)
def thumb(self):
return self._grab(FULL, "gray", "48x27!")
@staticmethod
def diff(a, b):
if not a or not b or len(a) != len(b):
return 255.0
return sum(abs(x - y) for x, y in zip(a, b)) / len(a)
def wait_stable(self, still=1.5, timeout=30, thresh=1.0):
t0 = time.time()
last, since = self.thumb(), time.time()
while time.time() - t0 < timeout:
time.sleep(0.4)
cur = self.thumb()
if self.diff(cur, last) > thresh or sum(cur) / max(1, len(cur)) < 3:
since = time.time()
elif time.time() - since >= still:
return cur
last = cur
return last
def classify(self):
"""black / letterbox (fly-by, cut scene) / map (world 9 beige map) / bright."""
b = self._grab(FULL, "rgb", "48x27!")
if len(b) != 48 * 27 * 3:
return "none"
px = [tuple(b[i:i + 3]) for i in range(0, len(b), 3)]
rows = [px[y * 48:(y + 1) * 48] for y in range(27)]
def mean(ys):
v = [sum(c) / 3 for y in ys for c in rows[y]]
return sum(v) / len(v)
if mean(range(27)) < 6:
return "black"
if mean((0,)) < 4 and mean((26,)) < 4 and mean(range(6, 18)) > 15:
return "letterbox"
beige = sum(1 for (r, g, bb) in px if 180 < r < 230 and 150 < g < 200 and 85 < bb < 140 and r > g > bb)
return "map" if beige > 0.75 * len(px) else "bright"
def hud_visible(self):
b = self._grab("900x110+100+10", "rgb", "25%")
return sum(1 for i in range(0, len(b) - 2, 3) if b[i] > 200 and b[i + 1] > 150 and b[i + 2] < 90) > 150
# ------------------------------------------------------------ crash detection
def crashed(self):
"""None, or why the game is considered crashed."""
if not self.game_pids():
return "groove.exe process gone"
gl = os.path.join(self.data, "logs", "game.log")
try:
with open(gl, "rb") as f:
f.seek(max(0, os.path.getsize(gl) - 65536))
tail = f.read().decode("latin-1")
for marker in ("Unhandled exception", "Unhandled page fault", "wine: Unhandled"):
if marker in tail:
return "game.log: " + tail[tail.index(marker):].splitlines()[0]
except OSError:
pass
if self.game_pids(b"winedbg"):
return "winedbg running (unhandled exception)"
if not self.find_window():
return "game window gone"
return None
def wait_for(self, pred, timeout, what, poll=0.7):
t0 = time.time()
while time.time() - t0 < timeout:
c = self.crashed()
if c:
raise Crash(c)
v = pred()
if v:
return v
time.sleep(poll)
raise Timeout("timeout (%d s) waiting for %s" % (timeout, what))
# ------------------------------------------------------------ navigation
def tolevel(self, world=None, skip_flyby=True):
"""Main menu -> "Spiel starten" -> slot 3 (rightmost) -> map (cursor from --unlock ... --cursor)
-> Enter -> skip the fly-by -> gameplay. Returns timings."""
T = {}
t0 = tx = time.time()
while True:
c = self.crashed()
if c:
raise Crash(c)
if fuzzy_in("spiel starten", self.ocr(MENU_CROP, threshold=75, psm=6, scale="100%")):
log("main menu")
time.sleep(0.5)
self.key("Return")
break
if fuzzy_in("gespeichertes", self.slot_text()):
break
if time.time() - tx > 40:
log("no menu yet -> Return (skips logo/demo)")
self.key("Return")
tx = time.time()
if time.time() - t0 > 120:
raise Timeout("main menu not reached")
time.sleep(1)
T["menu"] = round(time.time() - t0, 1)
self.wait_for(lambda: fuzzy_in("gespeichertes", self.slot_text()), 20, "save slot screen")
time.sleep(0.4)
for _ in range(2): # 3-slot slider without wrap: slot 3 is the rightmost
self.key("Right", 0.3)
time.sleep(0.3)
for i in range(3):
time.sleep(0.6)
t = self.slot_text()
if fuzzy_in("kapitel", t) and not fuzzy_in("leer", t):
log("slot: %s" % t.split(" | ")[0])
break
self.key("Right", 0.3)
else:
log("WARNING: slot 3 text not recognised (%s)" % t)
self.key("Return")
T["slot"] = round(time.time() - t0, 1)
if world == 9:
self.wait_for(lambda: self.classify() == "map", 30, "world 9 map")
time.sleep(1.0)
else:
self.wait_for(lambda: self.classify() not in ("black", "none"), 30, "map")
self.wait_stable(still=1.5, timeout=20)
self.key("Return")
t_load = time.time()
T["map"] = round(t_load - t0, 1)
skipped, good, states = False, 0, []
while time.time() - t_load < 150:
c = self.crashed()
if c:
raise Crash(c)
st = self.classify()
if not states or states[-1] != st:
states.append(st)
log("state: %s (%.1f s after level start)" % (st, time.time() - t_load))
if st == "letterbox" and skip_flyby and not skipped:
time.sleep(0.5)
self.key("Return")
skipped = True
elif st == "letterbox" and skipped and time.time() - t_load > 15:
self.key("x")
el = time.time() - t_load
if st == "bright" and el > 2 and (self.hud_visible() or "letterbox" in states or el > 25):
good += 1
if good >= 3:
break
else:
good = 0
time.sleep(0.6)
else:
raise Timeout("gameplay not reached (states %s)" % states)
T["gameplay"] = round(time.time() - t0, 1)
T["states"] = states
log("gameplay after %.1f s, states %s" % (T["gameplay"], states))
return T
def status(self):
s = {"display": self.display, "data": self.data, "game_pids": self.game_pids(),
"window": self.find_window() if self.display and self.state.get("xvfb_pid") else None,
"xvfb": bool(self.state.get("xvfb_pid") and _cmdline(self.state["xvfb_pid"])),
"pulse": bool(self.state.get("pulse_pid") and _cmdline(self.state["pulse_pid"]))}
s["audio_clients"] = self.audio_clients() if s["pulse"] else []
s["crashed"] = self.crashed() if s["game_pids"] and s["xvfb"] else None
return s
def run(sess, mods, opts, level, seq, out, keep=False):
os.makedirs(out, exist_ok=True)
report = {"mods": mods, "opts": opts, "level": level}
code = 0
try:
sess.setup()
report["apply"] = sess.apply(mods, opts).strip().splitlines()[-3:]
if level:
sess.unlock(3, level[0], level[1])
sess.start()
if level:
report["tolevel"] = sess.tolevel(world=level[0])
sess.shot(os.path.join(out, "00_start.png"))
if seq:
report["shots"] = sess.keys(seq, outdir=out)
c = sess.crashed()
if c:
raise Crash(c)
sess.shot(os.path.join(out, "99_end.jpg"), small=True)
report["audio_clients"] = sess.audio_clients()
except Crash as e:
report["error"] = "crash: %s" % e
code = 3
except Timeout as e:
report["error"] = str(e)
code = 4
finally:
if code and sess.state.get("xvfb_pid"):
try:
sess.shot(os.path.join(out, "zz_failure.jpg"), small=True)
except SystemExit:
pass
gl = os.path.join(sess.data, "logs", "game.log")
if os.path.exists(gl):
shutil.copy(gl, os.path.join(out, "game.log"))
al = os.path.join(sess.data, "logs", "apply.log")
if os.path.exists(al):
shutil.copy(al, os.path.join(out, "apply.log"))
json.dump(report, open(os.path.join(out, "report.json"), "w"), indent=1)
if not keep:
sess.stop()
log("result: %s" % (report.get("error") or "OK"))
return code
def main(argv):
if len(argv) < 2 or argv[0] in ("-h", "--help"):
print(__doc__)
return 2
cmd, name, rest = argv[0], argv[1], argv[2:]
kw, pos, opts = {}, [], []
i = 0
while i < len(rest):
a = rest[i]
if a in ("--display", "--root", "--jar", "--iso", "--mods", "--world", "--seq", "--out") and i + 1 < len(rest):
kw[a[2:]] = rest[i + 1]
i += 2
elif a == "--opt" and i + 1 < len(rest):
opts.append(rest[i + 1])
i += 2
elif a == "--level" and i + 2 < len(rest):
kw["level"] = (int(rest[i + 1]), int(rest[i + 2]))
i += 3
else:
pos.append(a)
i += 1
s = Session(name, display=kw.get("display"), root=kw.get("root", DEFAULT_ROOT), jar=kw.get("jar"),
iso=kw.get("iso", DEFAULT_ISO))
try:
if cmd == "setup":
s.setup()
elif cmd == "apply":
ids = [x for p in pos for x in p.split(",") if x] or [x for x in kw.get("mods", "").split(",") if x]
print(s.apply(ids, opts))
elif cmd == "unlock":
print(s.unlock(*pos[:3]))
elif cmd == "start":
s.start()
elif cmd == "tolevel":
print(json.dumps(s.tolevel(world=int(kw["world"]) if "world" in kw else None)))
elif cmd == "keys":
print(s.keys(pos, outdir=kw.get("out")))
elif cmd == "shot":
print(s.shot(pos[0], small="--small" in pos))
elif cmd == "ocr":
print(s.ocr(pos[0] if pos else FULL))
elif cmd == "status":
st = s.status()
print(json.dumps(st, indent=1))
return 3 if st["crashed"] else 0
elif cmd == "stop":
s.stop()
elif cmd == "run":
if not s.display or "out" not in kw:
print("run needs --display N and --out DIR")
return 2
mods = [x for x in kw.get("mods", "").split(",") if x]
return run(s, mods, opts, kw.get("level"), kw.get("seq"), kw["out"], keep="--keep" in pos)
else:
print(__doc__)
return 2
except Crash as e:
log("CRASH: %s" % e)
return 3
except Timeout as e:
log("TIMEOUT: %s" % e)
return 4
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
+73
View File
@@ -0,0 +1,73 @@
#!/usr/bin/env python3
"""Dekodiert Argonaut-ASF-Musik (v2.1, 4-Bit-ADPCM) wie asfcodec.adl aus dem Spiel.
Nachgebaut aus der Disassembly von asfcodec.adl (Routinen 0x26e01969 / 0x26e01ca1):
Filter 0: s = s1 + (n << shift)
Filter 1: s = 2*s1 - s2 + (n << shift) (+ Übertrag aus 'adc', s. u.)
- kein Clipping; Ausgabe wird als 16 Bit gespeichert (Überlauf = Wrap)
- s1 läuft innerhalb eines Blocks ungekürzt im Register weiter,
am Blockanfang wird es aus dem 16-Bit-Puffer gelesen
- s2 wird immer aus dem 16-Bit-Puffer gelesen
Aufruf: asf2wav.py eingabe.asf ausgabe.wav
"""
import array
import struct
import sys
import wave
M32 = 0xFFFFFFFF
def s32(x):
x &= M32
return x - (1 << 32) if x & 0x80000000 else x
def wrap16(x):
return ((x + 0x8000) & 0xFFFF) - 0x8000
def decode(data):
off = struct.unpack_from('<I', data, 12)[0]
nblocks, _, _, rate, _, flags = struct.unpack_from('<IIIHHI', data, off)
ch = 2 if flags & 2 else 1
p = off + 20
out = array.array('h', bytes(nblocks * 32 * ch * 2))
mem = [[0, 0] for _ in range(ch)] # gespeicherte 16-Bit-Werte: [vorletztes, letztes]
o = 0
for _ in range(nblocks):
for c in range(ch):
ctl = data[p]
blk = data[p + 1:p + 17]
p += 17
cl = (ctl >> 4) + 2
prev2, prev1 = mem[c]
eax = prev1 # Blockanfang: s1 aus dem Speicher
k = o + c
for byte in blk:
for nib in (byte >> 4, byte & 15):
n = nib - 16 if nib & 8 else nib
edx = s32((n * 16) << cl)
if ctl & 4:
ebx = s32(eax << 7)
a = s32(-prev2 << 6)
carry = ((a & M32) + (edx & M32)) >> 32
eax = s32(a + edx + ebx + carry) >> 6
else:
eax = s32((eax << 6) + edx) >> 6
w = wrap16(eax)
out[k] = w
k += ch
prev2, prev1 = prev1, w
mem[c] = [prev2, prev1]
o += 32 * ch
return rate, ch, out
if __name__ == '__main__':
rate, ch, pcm = decode(open(sys.argv[1], 'rb').read())
with wave.open(sys.argv[2], 'wb') as w:
w.setnchannels(ch)
w.setsampwidth(2)
w.setframerate(rate)
w.writeframes(pcm.tobytes())
+44
View File
@@ -0,0 +1,44 @@
#!/usr/bin/env python3
"""Entpackt Argonaut-.cor-Bilder (Ein Königreich für ein Lama) nach PNG.
Aufbau: 8 Byte Signatur 17 98 AF 89 FE 42 D1 12, u32 Version (1),
u32 entpackte Größe, u32 gepackte Größe, dann LZ-Daten:
n < 0x80 -> n Bytes Rohdaten folgen
1LLL oooo oooooooo -> kopiere L+3 Bytes ab o Bytes weiter vorne
Das Ergebnis sind 640x480 Pixel à 3 Byte (RGB) oder à 1 Byte (Graustufen, z. B. 151.cor).
Aufruf: cor2png.py eingabe.cor ausgabe.png (benötigt Pillow)
"""
import struct
import sys
MAGIC = bytes.fromhex('1798af89fe42d112')
def unpack(data):
if data[:8] != MAGIC:
raise ValueError('keine COR-Datei')
_, size, packed = struct.unpack_from('<III', data, 8)
src = data[20:20 + packed]
out = bytearray()
i = 0
while i < len(src) and len(out) < size:
b = src[i]
if b < 0x80:
out += src[i + 1:i + 1 + b]
i += 1 + b
else:
length = ((b >> 4) & 7) + 3
off = ((b & 0x0F) << 8) | src[i + 1]
i += 2
for _ in range(length):
out.append(out[-off])
return bytes(out), size
if __name__ == '__main__':
raw, size = unpack(open(sys.argv[1], 'rb').read())
print(f'{sys.argv[1]}: {len(raw)} von {size} Bytes')
if len(sys.argv) > 2:
from PIL import Image
mode = {640 * 480 * 3: 'RGB', 640 * 480: 'L'}[size]
Image.frombytes(mode, (640, 480), raw).save(sys.argv[2])
+729
View File
@@ -0,0 +1,729 @@
#!/usr/bin/env python3
"""Debug-Visualisierung fuer Level-WADs von "Ein Koenigreich fuer ein Lama".
Macht Kollisionsgeometrie (auch unsichtbare Waende/Boeden) und unsichtbare Logik-Objekte
(Kameras, Trigger, Ausgaenge, Checkpoints, Kraftzonen ...) im Spiel sichtbar.
debugviz.py IN.wad OUT.wad [Optionen] ein Level
debugviz.py --all OUTDIR [--src CD-wads-Ordner] alle Level-WADs (t1l*, t6l*)
Optionen: --no-coll --no-markers --no-fill (nur Kanten) --opaque (Fuellung deckend)
--width 0.03 (Kantenbreite) --offset 0.012 (Abstand entlang der Normale)
--report datei.json
Prinzip (Details: notes/debugviz.md):
* Kollision: an jedes TRAK-Modell werden Render-Dreiecke angehaengt (Rahmen entlang der
Kanten jedes Kollisionspolygons, leicht entlang der Normale versetzt; bei unsichtbarer
Kollision zusaetzlich halbtransparente Fuellung). Damit stimmen Kachel-Transformation,
Zonen/Sichtbarkeit und Kachel-Wechsel automatisch. Kollisionspolygone werden NICHT
veraendert/ergaenzt. Vertexfarben-Block in MAP waechst je Kachel und Farbsatz mit.
* Objekte: Marker (Wuerfel, Linien, Boxen) als neue Kacheln ohne Kollision, je Rasterzelle
eine Kachel (also in der richtigen Zone). Strats bleiben unveraendert.
* Farben: angehaengte Flachfarb-Texturen (Flags 0x105 deckend, 0x1105 = Blendmodus 1
Alpha-Blending, Alpha im 4. Byte).
"""
import argparse
import collections
import glob
import json
import math
import os
import struct
import sys
import numpy as np
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import geomlib as G # noqa: E402
import mapchunk # noqa: E402
import strats as S # noqa: E402
CD_WADS = os.path.expanduser('~/.wine-lama/iso-dk/Setup/Eng/Cd/wads')
# ------------------------------------------------------------------ Farben
KIND_COL = {
'boden': (40, 255, 40), # begehbar (Normale steil nach oben)
'schraege': (200, 255, 0), # schraeg
'wand': (0, 150, 255), # Wand
'decke': (255, 130, 0), # Decke / Ueberhang
}
INVIS_FILL = (255, 0, 0) # unsichtbare Kollision (keine Render-Flaeche)
FILL_ALPHA = 96
# Material -> (Name, Farbe). Unbekannte Materialien bekommen Farben aus PALETTE.
MAT_INFO = {
16: ('Ausgang (LevelDoor-Boden)', (190, 60, 255)),
17: ('selektiv 0x11 (nur Objekte mit Flag 0x8000000)', (255, 255, 0)),
18: ('selektiv 0x12 (nicht fuer Objekte mit Flag 0x8000000)', (255, 200, 120)),
30: ('kein Boden / Fall-/Todesebene (0x1e)', (255, 0, 255)),
}
# feste Farben fuer die uebrigen Materialien (Bedeutung im Code nicht geklaert, siehe notes/debugviz.md)
MAT_FIXED = {
1: (0, 200, 200), 2: (255, 255, 255), 3: (255, 120, 170), 5: (120, 255, 200), 6: (160, 120, 255),
7: (255, 170, 80), 8: (0, 130, 60), 9: (150, 200, 255), 10: (150, 150, 0), 11: (150, 90, 40),
13: (255, 130, 110), 15: (255, 255, 150), 20: (0, 120, 140), 26: (110, 40, 160), 27: (190, 190, 190),
28: (255, 0, 140), 33: (150, 0, 0), 255: (20, 20, 20),
}
MAT_DEFAULT = (230, 230, 230)
MARK_COL = {
'start': (255, 255, 255),
'ausgang': (190, 60, 255),
'checkpoint': (0, 255, 140),
'kamera': (0, 255, 255),
'trigger': (255, 255, 0),
'kraft': (255, 90, 0),
'geheimnis': (255, 200, 0),
'sonst': (170, 170, 170),
'umgebung': (255, 100, 180),
'objekt': (90, 90, 90),
}
MARK_H = 0.35 # Markerwuerfel ueber dem Strat-Ursprung
MARK_SIZE = 0.05 # halbe Kantenlaenge
START_T = {'Llama', 'KuscoAndPachaTogetherOnATree', 'RollerCoaster', 'LlamaChase'}
EXIT_T = {'LevelDoor', 'VillageEndDoor', 'EndGameDoor', 'TeleportDoor'}
FORCE_T = {'FallOffNoHurt', 'ObjectDontDropInBox', 'RiverCurrentBox', 'RiverRebound',
'SuckingWPs', 'ObjectSuckPoints', 'RiverTrackChange', 'TrackSwitch',
'BackgroundRotationChanger', 'RiverWhirlpool'}
MISC_T = {'CheatTeleport', 'Wompy', 'WompySimple', 'WompyRollerCoaster', 'Rain', 'DrippingWater',
'WaterfallMist', 'Smoker', 'CoinLine', 'CoinCircle', 'CoinRollerCoaster', 'CoinBat',
'CoinSpawnOracle', 'MusicBox', 'SpitPlatformTarget', 'NewBlockheadKidHelper',
'FlockOfBirds', 'Bats', 'RiverBats', 'Scorpions1', 'Scorpions3', 'Scorpions5',
'GhostCatacombs', 'LabDumbGuards', 'FlyStuck', 'AppearingPlatforms', 'AppearingPlatformsCity',
'AppearingPlatformsJungle', 'AppearingPlatformsLab', 'AppearingPlatformsVillage', 'fat_bird',
'Column2', 'TipoHelp'}
TRIG_T = {'ChangeToBunny', 'ChangeToTurtle', 'RiverStartSign'}
def mark_cat(name):
n = name.replace('(spawn)', '')
if n in START_T:
return 'start'
if n in EXIT_T:
return 'ausgang'
if n.startswith('CheckPoint'):
return 'checkpoint'
if 'Camera' in n:
return 'kamera'
if n == 'Secret':
return 'geheimnis'
if n in FORCE_T or n.startswith('Force') or n.startswith('WaterRebound'):
return 'kraft'
if 'Trigger' in n or n.startswith('Say') or n in TRIG_T:
return 'trigger'
if n in MISC_T or n.endswith('Spawner') or 'Helper' in n:
return 'sonst'
return None
# ------------------------------------------------------------------ TEXT: Flachfarben anhaengen
def text_append_flats(d, entries):
"""entries: Liste (r,g,b,alpha|None). Liefert (neues TEXT, erster neuer Index)."""
o = 0
npal = struct.unpack_from('<I', d, o)[0]; o += 4
for _ in range(npal):
n = struct.unpack_from('<I', d, o + 4)[0]; o += 8 + 3 * n
npg = struct.unpack_from('<I', d, o)[0]; o += 4
for _ in range(npg):
fl, w, h = struct.unpack_from('<3I', d, o); o += 12
if fl == 0:
o += w * h
elif fl & 0x80:
o += 4 + struct.unpack_from('<I', d, o)[0]
else:
o += w * h * 2
nt = struct.unpack_from('<I', d, o)[0]
add = b''
for r, g, b, a in entries:
if a is None:
add += struct.pack('<HbbBBBB', 0x105, -1, -1, r, g, b, 0)
else: # Blendmodus 1 (Bits 12-14) = SRCALPHA/INVSRCALPHA, Alpha = 4. Byte
add += struct.pack('<HbbBBBB', 0x1105, -1, -1, r, g, b, a)
e = o + 4 + 8 * nt
return d[:o] + struct.pack('<I', nt + len(entries)) + d[o + 4:e] + add + d[e:], nt
class Palette:
def __init__(self):
self.entries = []
self.idx = {}
self.base = 0
def __call__(self, rgb, alpha=None):
k = (tuple(rgb), alpha)
if k not in self.idx:
self.idx[k] = len(self.entries)
self.entries.append((*rgb, alpha))
return self.idx[k] # relativ; + base beim Schreiben
# ------------------------------------------------------------------ Geometrie-Helfer
def norm(v):
n = np.linalg.norm(v)
return v / n if n > 1e-12 else v
class Mesh:
"""Dreiecksliste mit Paletten-Index; Verts (pos, normal)."""
def __init__(self):
self.verts = []
self.tris = [] # (a, b, c, palidx)
def v(self, p, n):
self.verts.append((float(p[0]), float(p[1]), float(p[2]), float(n[0]), float(n[1]), float(n[2])))
return len(self.verts) - 1
def quad(self, p, n, col):
"""4 Punkte (Ring), eine Normale."""
ids = [self.v(q, n) for q in p]
self.tris.append((ids[0], ids[1], ids[2], col))
self.tris.append((ids[0], ids[2], ids[3], col))
def beam(self, a, b, w, col):
"""Linie a->b als Kreuz aus zwei doppelseitigen Streifen."""
a = np.asarray(a, float); b = np.asarray(b, float)
d = b - a
L = np.linalg.norm(d)
if L < 1e-6:
return
d /= L
up = np.array([0, 1.0, 0]) if abs(d[1]) < 0.9 else np.array([1.0, 0, 0])
s1 = norm(np.cross(d, up)) * (w / 2)
s2 = norm(np.cross(d, s1)) * (w / 2)
for s in (s1, s2):
n = norm(np.cross(d, s))
self.quad([a - s, b - s, b + s, a + s], n, col)
def cube(self, c, h, col):
c = np.asarray(c, float)
for ax in range(3):
for sg in (-1, 1):
n = np.zeros(3); n[ax] = sg
u = np.zeros(3); u[(ax + 1) % 3] = h
v = np.zeros(3); v[(ax + 2) % 3] = h
f = c + n * h
self.quad([f - u - v, f + u - v, f + u + v, f - u + v], n, col)
def seg_split(a, b, maxlen=1.0):
a = np.asarray(a, float); b = np.asarray(b, float)
n = max(1, int(math.ceil(np.linalg.norm(b - a) / maxlen)))
return [(a + (b - a) * k / n, a + (b - a) * (k + 1) / n) for k in range(n)]
# ------------------------------------------------------------------ Kollision klassifizieren
def kind_of(ny):
if ny >= 0.7:
return 'boden'
if ny >= 0.2:
return 'schraege'
if ny > -0.2:
return 'wand'
return 'decke'
def decode_model_coll(m):
out = []
for g, grp in enumerate(m.coll):
for r in grp:
c = G.coll_decode(r)
pts = G.coll_polygon(c)
ok = len(pts) >= 3 and all(abs(x) < 12 for p in pts for x in p)
P = np.array(pts, float) if ok else None
if ok:
# Flaeche pruefen
ar = 0.0
for k in range(1, len(P) - 1):
ar += np.linalg.norm(np.cross(P[k] - P[0], P[k + 1] - P[0])) / 2
if ar < 1e-5:
ok = False
n = norm(np.array(c['plane'][:3]))
out.append(dict(c=c, pts=P if ok else None, n=n, mat=c['mat'], typ=c['typ'], grp=g,
kind=kind_of(n[1])))
return out
class RenderIndex:
"""Render-Dreiecke der Welt (Exportraum), nach Rasterzellen gebuckelt."""
def __init__(self, trak, mp):
cells = collections.defaultdict(list)
A, B, C = [], [], []
for i in range(mp['N']):
x, y, z, rot, mi, fl = mp['A'][i]
m = trak[mi]
if not m.faces:
continue
f, _ = G.tile_xform(x, y, z, rot)
W = np.array([f(*v[:3]) for v in m.verts])
for fc in m.faces:
a, b, c = (W[k] for k in fc['v'])
A.append(a); B.append(b); C.append(c)
self.A = np.array(A); self.B = np.array(B); self.C = np.array(C)
N = np.cross(self.B - self.A, self.C - self.A)
ln = np.linalg.norm(N, axis=1)
good = ln > 1e-10
N[good] /= ln[good, None]
self.N = N
self.D = np.einsum('ij,ij->i', N, self.A)
lo = np.minimum(np.minimum(self.A, self.B), self.C)
hi = np.maximum(np.maximum(self.A, self.B), self.C)
for t in np.nonzero(good)[0]:
for cx in range(int(math.floor(lo[t, 0])), int(math.floor(hi[t, 0])) + 1):
for cz in range(int(math.floor(lo[t, 2])), int(math.floor(hi[t, 2])) + 1):
cells[(cx, cz)].append(t)
self.cells = {k: np.array(v) for k, v in cells.items()}
def covered(self, p, n, dist=0.5, cosmin=0.4, tol=0.02):
"""Trifft der Strahl p + t*n (|t| <= dist) ein Render-Dreieck (Neigung zur Normale < ~66 Grad)?"""
ids = set()
for q in (p, p + n * dist, p - n * dist):
k = (int(math.floor(q[0])), int(math.floor(q[2])))
if k in self.cells:
ids.update(self.cells[k].tolist())
if not ids:
return False
ids = np.fromiter(ids, int)
N = self.N[ids]
cs = N @ n
sel = np.abs(cs) > cosmin
if not sel.any():
return False
ids, N, cs = ids[sel], N[sel], cs[sel]
t = (self.D[ids] - N @ p) / cs
sel = np.abs(t) <= dist
if not sel.any():
return False
ids, t = ids[sel], t[sel]
q = p[None, :] + t[:, None] * n[None, :]
a, b, c = self.A[ids], self.B[ids], self.C[ids]
v0, v1 = b - a, c - a
v2 = q - a
d00 = np.einsum('ij,ij->i', v0, v0); d01 = np.einsum('ij,ij->i', v0, v1)
d11 = np.einsum('ij,ij->i', v1, v1)
d20 = np.einsum('ij,ij->i', v2, v0); d21 = np.einsum('ij,ij->i', v2, v1)
den = d00 * d11 - d01 * d01
den[den == 0] = 1e-12
v = (d11 * d20 - d01 * d21) / den
w = (d00 * d21 - d01 * d20) / den
u = 1 - v - w
return bool(((u > -tol) & (v > -tol) & (w > -tol)).any())
def classify(trak, mp, decoded):
"""Setzt pro Polygon 'invis' (True = keine Render-Flaeche deckt es)."""
ri = RenderIndex(trak, mp)
votes = collections.defaultdict(lambda: [0, 0]) # (model, k) -> [sichtbar, unsichtbar]
inst = collections.defaultdict(list)
altmap = dict(zip([i for i in range(mp['N']) if mp['A'][i][5]], mp['anim']))
for i in range(mp['N']):
inst[mp['midx'][i]].append(i)
if i in altmap:
inst[altmap[i][4]].append(i)
for mi, polys in enumerate(decoded):
tiles = inst.get(mi, [])[:6] # wenige Instanzen reichen
for k, pl in enumerate(polys):
if pl['pts'] is None:
continue
P = pl['pts']
cen = P.mean(axis=0)
samples = [cen] + [cen + (q - cen) * 0.7 for q in P]
if not tiles:
votes[(mi, k)][1] += 1 # nie platziert: ohne Weltbezug -> als unsichtbar werten
continue
for ti in tiles:
x, y, z, rot, _, _ = mp['A'][ti]
f, fn = G.tile_xform(x, y, z, rot)
nw = np.array(fn(*pl['n']))
hit = sum(ri.covered(np.array(f(*s)), nw) for s in samples)
votes[(mi, k)][0 if hit >= 0.6 * len(samples) else 1] += 1
for mi, polys in enumerate(decoded):
for k, pl in enumerate(polys):
vs = votes.get((mi, k), [1, 0])
pl['invis'] = vs[1] > vs[0]
# ------------------------------------------------------------------ Overlay je Modell
def mat_color(mat, matcols):
if mat in MAT_INFO:
return MAT_INFO[mat][1]
matcols[mat] = MAT_FIXED.get(mat, MAT_DEFAULT)
return matcols[mat]
def poly_overlay(mesh, pl, pal, opt, matcols):
P = pl['pts']
if P is None:
return
n = pl['n']
c = pl['c']
mat = pl['mat']
ecol = KIND_COL[pl['kind']] if mat == 0 else mat_color(mat, matcols)
off = n * opt.offset
cen = P.mean(axis=0)
# Inkreis-Abschaetzung: kleinster Abstand Mittelpunkt -> Kante
rmin = 1e9
for k in range(len(P)):
a, b = P[k], P[(k + 1) % len(P)]
e = b - a
L = np.linalg.norm(e)
if L < 1e-9:
continue
rmin = min(rmin, np.linalg.norm(np.cross(e, cen - a)) / L)
w = min(opt.width, 0.4 * rmin)
# innere Ecken: Kantengeraden um w nach innen verschoben, mit Flaechenebene schneiden
E = c['edges']
m = len(E)
ok = True
pts2 = []
for k in range(m):
e1, e2 = E[k], E[(k + 1) % m]
Am = np.array([c['plane'][:3], e1[:3], e2[:3]])
Bm = np.array([c['plane'][3], e1[3] + w * np.linalg.norm(e1[:3]), e2[3] + w * np.linalg.norm(e2[:3])])
try:
pts2.append(np.linalg.solve(Am, Bm))
except np.linalg.LinAlgError:
ok = False
break
if ok and len(pts2) == len(P) and all(np.linalg.norm(pts2[k] - P[k]) < 4 * w + 0.05 for k in range(len(P))):
inner = pts2
else:
inner = [cen + (q - cen) * 0.85 for q in P]
O = [mesh.v(q + off, n) for q in P] # Ecken einmal anlegen (Rahmen + Fuellung teilen sie)
I = [mesh.v(q + off, n) for q in inner]
ec = pal(ecol)
for k in range(len(P)):
k2 = (k + 1) % len(P)
mesh.tris.append((O[k], O[k2], I[k2], ec))
mesh.tris.append((O[k], I[k2], I[k], ec))
fill = None
if not opt.no_fill:
if mat == 16:
fill = mat_color(mat, matcols)
elif pl['invis'] and mat != 30:
fill = INVIS_FILL if mat == 0 else mat_color(mat, matcols)
if fill is not None:
fc = pal(fill, None if opt.opaque else FILL_ALPHA)
ids = I
for k in range(1, len(ids) - 1):
mesh.tris.append((ids[0], ids[k], ids[k + 1], fc))
def append_mesh(model, mesh, pal_base, limit=7900):
"""Haengt Mesh an Modell an (Batch-Flag 0x8 bei Texturwechsel / alle 256 Faces)."""
nv0 = len(model.verts)
if nv0 + len(mesh.verts) > limit:
return 0
model.verts += mesh.verts
tris = sorted(mesh.tris, key=lambda t: t[3])
last, run = None, 0
for a, b, c, col in tris:
tex = pal_base + col
fl = 0x0800 | 0x0400 | 0x4000
if tex != last or run >= 250:
fl |= 0x8
last, run = tex, 0
run += 1
p0, p1, p2 = (model.verts[nv0 + k][:3] for k in (a, b, c))
model.faces.append(dict(flags=fl, v=(nv0 + a, nv0 + b, nv0 + c), tex=tex, pad=0,
plane=G.face_plane(p0, p1, p2)))
model.dirty = True
return len(mesh.verts)
# ------------------------------------------------------------------ Marker fuer Objekte
def fx(v):
v = v - (1 << 32) if v >= 1 << 31 else v
return v / 4096
def strat_markers(ch, wf, pal, stats):
"""Liefert Liste (welt-Mesh-Teile) als dict cell -> Mesh in Weltkoordinaten (Exportraum)."""
mm = mapchunk.parse(G.wad_get(ch, 'MAP'), wf, mapchunk.trak_vertcounts(G.wad_get(ch, 'TRAK')))
st = G.wad_get(ch, 'STPC')
import stpcwalk
lo = stpcwalk.walk(st, wf)['code']
nodes = mm['nodes']
segs = [] # (a, b, breite, farbe) Weltraum Spielkoordinaten (z positiv)
cubes = [] # (c, h, farbe)
quads = [] # (4 Punkte, farbe, alpha)
per_cat = collections.Counter()
per_type = collections.Counter()
def chain(w):
out = []
while 0 <= w < len(nodes) and len(out) < 200:
nd = nodes[w]
out.append(np.array([fx(nd[5]), fx(nd[6]), fx(nd[7])]))
if nd[0] == 0:
break
w = nd[0]
return out
def box(lo_, hi_, col, w=0.04):
x0, y0, z0 = lo_; x1, y1, z1 = hi_
P = [np.array(p) for p in [(x0, y0, z0), (x1, y0, z0), (x1, y0, z1), (x0, y0, z1),
(x0, y1, z0), (x1, y1, z0), (x1, y1, z1), (x0, y1, z1)]]
for a, b in [(0, 1), (1, 2), (2, 3), (3, 0), (4, 5), (5, 6), (6, 7), (7, 4), (0, 4), (1, 5), (2, 6), (3, 7)]:
segs.append((P[a], P[b], w, col))
for s in mm['strats']:
name = S.strat_name(st, s[6], lo) or '?'
cat = mark_cat(name)
if cat is None:
# alle uebrigen Objekte: kleiner dunkler Punkt am Ursprung (nichts geht verloren)
per_cat['objekt'] += 1
cubes.append((np.array([fx(s[3]), fx(s[4]), fx(s[5])]) + [0, 0.06, 0], 0.03, MARK_COL['objekt']))
continue
per_cat[cat] += 1
per_type[name] += 1
col = MARK_COL[cat]
p = np.array([fx(s[3]), fx(s[4]), fx(s[5])])
rot = s[1] / 4096 * 2 * math.pi
# Marker: Wuerfel + Pfahl
top = p + [0, MARK_H, 0]
if cat == 'start':
# Pfeil flach auf dem Boden (blockiert die Startkamera nicht)
d = np.array([math.sin(rot), 0, math.cos(rot)]) # 0 = +z, 90 Grad = +x
q = np.array([d[2], 0, -d[0]])
g = p + [0, 0.03, 0]
segs.append((g - d * 0.3, g + d * 0.4, 0.025, col))
segs.append((g + d * 0.4, g + d * 0.2 + q * 0.15, 0.025, col))
segs.append((g + d * 0.4, g + d * 0.2 - q * 0.15, 0.025, col))
else:
cubes.append((top, MARK_SIZE, col))
segs.append((p, top, 0.01, col))
if cat in ('kamera', 'checkpoint'):
d = np.array([math.sin(rot), 0, math.cos(rot)])
segs.append((top, top + d * 0.3, 0.012, col))
if cat == 'ausgang':
cx, cz = math.floor(p[0]), math.floor(p[2])
box((cx, p[1] - 0.05, cz), (cx + 1, p[1] + 1.0, cz + 1), col, 0.02)
ch_ = chain(s[13]) if s[13] >= 0 else []
if len(ch_) == 2:
a, b = ch_
lo_ = np.minimum(a, b); hi_ = np.maximum(a, b)
inside = (lo_[0] - 0.4 <= p[0] <= hi_[0] + 0.4) and (lo_[2] - 0.4 <= p[2] <= hi_[2] + 0.4)
if inside and hi_[0] - lo_[0] > 0.2 and hi_[2] - lo_[2] > 0.2:
y0 = min(lo_[1], p[1]) - 0.02
y1 = max(hi_[1], p[1]) + 1.2
box((lo_[0], y0, lo_[2]), (hi_[0], y1, hi_[2]), col, 0.02)
stats['boxen'] += 1
if (hi_[0] - lo_[0]) * (hi_[2] - lo_[2]) <= 64:
quads.append(([(lo_[0], y0 + 0.03, lo_[2]), (hi_[0], y0 + 0.03, lo_[2]),
(hi_[0], y0 + 0.03, hi_[2]), (lo_[0], y0 + 0.03, hi_[2])], col))
continue
if ch_ and cat in ('kamera', 'trigger', 'kraft', 'sonst', 'geheimnis', 'checkpoint', 'start'):
dim = tuple(int(c * 0.6) for c in col)
prev = p + [0, 0.45, 0]
for k, q in enumerate(ch_):
cubes.append((q, 0.025, dim))
if cat == 'kraft':
segs.append((prev, q, 0.008, dim))
prev = q
stats['pfadpunkte'] += len(ch_)
# Umgebungszonen (MAP-Block 4, 90 Byte: Zelle x/y/z, Nebel, Musik ...) -> Drahtwuerfel je Zelle
col = MARK_COL['umgebung']
for L in mm['lights']:
cx, cy, cz = L[0], L[1], L[2]
if L[3:6] == (1, 1, 1):
continue # Vorgabe-Eintrag (ganzes Level)
box((cx + 0.04, cy, cz + 0.04), (cx + 0.96, cy + 0.92, cz + 0.96), col, 0.02)
per_cat['umgebung'] += 1
# alles in Zellen buckeln (Exportraum: z negieren)
cells = collections.defaultdict(Mesh)
def cell_of(q):
return (int(math.floor(q[0])), int(math.floor(q[2])))
def ex(q):
return np.array([q[0], q[1], -q[2]])
for a, b, w, col in segs:
for s0, s1 in seg_split(a, b, 1.0):
m = (s0 + s1) / 2
cells[cell_of(m)].beam(ex(s0), ex(s1), w, pal(col))
for c, h, col in cubes:
cells[cell_of(c)].cube(ex(c), h, pal(col))
for pts, col in quads:
# in <=1x1-Stuecke zerlegen
(x0, y, z0), _, (x1, _, z1), _ = pts
xs = np.arange(math.floor(x0), math.ceil(x1))
zs = np.arange(math.floor(z0), math.ceil(z1))
for cx in xs:
for cz in zs:
a0, a1 = max(x0, cx), min(x1, cx + 1)
b0, b1 = max(z0, cz), min(z1, cz + 1)
if a1 - a0 < 1e-3 or b1 - b0 < 1e-3:
continue
ms = cells[(int(cx), int(cz))]
q = [ex((a0, y, b0)), ex((a1, y, b0)), ex((a1, y, b1)), ex((a0, y, b1))]
ids = [ms.v(t, (0, 1, 0)) for t in q]
fc = pal(col, 70)
ms.tris.append((ids[0], ids[1], ids[2], fc))
ms.tris.append((ids[0], ids[2], ids[3], fc))
stats['marker_kat'] = dict(per_cat)
stats['marker_typen'] = dict(per_type)
return cells
# ------------------------------------------------------------------ Hauptablauf
def process(src, dst, opt):
ch = G.wad_read(src)
wf = G.wfpc_flags(ch)
trak = G.parse_trak(G.wad_get(ch, 'TRAK'))
old_vc = [len(m.verts) for m in trak]
mp = G.parse_map_tiles(G.wad_get(ch, 'MAP'), wf, old_vc)
pal = Palette()
matcols = {}
stats = collections.Counter()
info = dict(level=os.path.basename(src))
mats = collections.Counter()
add_v = [0] * len(trak)
if not opt.no_coll:
decoded = [decode_model_coll(m) for m in trak]
classify(trak, mp, decoded)
# Statistik je Kachelinstanz
inst = collections.Counter(mp['midx'])
for mi, polys in enumerate(decoded):
for pl in polys:
mats[pl['mat']] += 1
stats['koll_polys'] += 1
if pl['pts'] is None:
stats['koll_kaputt'] += 1
elif pl['invis']:
stats['koll_unsichtbar'] += 1
stats['koll_unsichtbar_inst'] += inst.get(mi, 0)
for mi, m in enumerate(trak):
mesh = Mesh()
for pl in decoded[mi]:
poly_overlay(mesh, pl, pal, opt, matcols)
add_v[mi] = len(mesh.verts)
mesh_store = mesh
trak[mi]._ov = mesh_store
# Marker
cells = {}
if not opt.no_markers:
cells = strat_markers(ch, wf, pal, stats)
# TEXT erweitern
text, base = text_append_flats(G.wad_get(ch, 'TEXT'), pal.entries)
G.wad_set(ch, 'TEXT', text)
for mi, m in enumerate(trak):
ov = getattr(m, '_ov', None)
if ov is not None and ov.verts:
got = append_mesh(m, ov, base)
if got == 0:
stats['modelle_zu_gross'] += 1
add_v[mi] = 0
# neue Marker-Kacheln
W, H = mp['W'], mp['H']
new_tiles = []
for (cx, cz), mesh in sorted(cells.items()):
if not mesh.verts:
continue
if not (0 <= cx < W and 0 <= cz < H):
stats['marker_ausserhalb'] += 1
continue
ys = [v[1] for v in mesh.verts]
oy = round((min(ys) + max(ys)) / 2 * 4) / 4
ox, oz = cx + 0.5, -(cz + 0.5)
mdl = G.Model()
loc = Mesh()
loc.verts = [(v[0] - ox, v[1] - oy, v[2] - oz, v[3], v[4], v[5]) for v in mesh.verts]
loc.tris = mesh.tris
# zu grosse Zellen aufteilen
if len(loc.verts) > 7900:
stats['marker_gekuerzt'] += 1
keep = [t for t in loc.tris if max(t[:3]) < 7900]
loc.tris = keep
loc.verts = loc.verts[:7900]
append_mesh(mdl, loc, base)
trak.append(mdl)
new_tiles.append(dict(x=ox, y=oy, z=oz, rot=0, model=len(trak) - 1,
cols=[[0x00808080] * len(mdl.verts)]))
stats['marker_kacheln'] = len(new_tiles)
# MAP neu
altmap = dict(zip([i for i in range(mp['N']) if mp['A'][i][5]], mp['anim']))
tiles = []
for i in range(mp['N']):
x, y, z, rot, mi, fl = mp['A'][i]
ext = [0x00808080] * add_v[mi]
t = dict(x=x, y=y, z=z, rot=rot, model=mi, cols=[fr + ext for fr in mp['cols'][i]], flag=fl)
if i in altmap:
am = altmap[i][4]
t['altcols'] = [fr + [0x00808080] * add_v[am] for fr in mp['altcols'][i]]
tiles.append(t)
tiles += new_tiles
newmap = G.build_map_tiles(G.wad_get(ch, 'MAP'), wf, old_vc, tiles, zones='keep', anim=mp['anim'])
G.wad_set(ch, 'TRAK', G.build_trak(trak))
G.wad_set(ch, 'MAP', newmap)
# Gegenprobe: MAP vollstaendig parsen, Strats unveraendert
vc = [len(m.verts) for m in trak]
a = mapchunk.parse(newmap, wf, vc)
b = mapchunk.parse(G.wad_get(G.wad_read(src), 'MAP'), wf, old_vc)
assert a['rest'] == b['rest'] and a['strats'] == b['strats'] and a['nodes'] == b['nodes']
assert a['tiles'][:mp['N']] == b['tiles']
nv_max = max(vc)
G.wad_write(dst, ch)
info.update(stats)
info['materialien'] = {str(k): v for k, v in sorted(mats.items())}
info['materialfarben'] = {str(k): v for k, v in matcols.items()}
info['max_verts_modell'] = nv_max
info['max_faces_modell'] = max(len(m.faces) for m in trak)
info['texturen_neu'] = len(pal.entries)
info['groesse_alt'] = os.path.getsize(src)
info['groesse_neu'] = os.path.getsize(dst)
info['kacheln'] = mp['N'] + len(new_tiles)
info['modelle'] = len(trak)
return info
def main():
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument('inp', nargs='?')
ap.add_argument('out', nargs='?')
ap.add_argument('--all', metavar='OUTDIR')
ap.add_argument('--src', default=CD_WADS)
ap.add_argument('--only', help='Kommagetrennte Levelnamen (mit --all)')
ap.add_argument('--no-coll', action='store_true')
ap.add_argument('--no-markers', action='store_true')
ap.add_argument('--no-fill', action='store_true')
ap.add_argument('--opaque', action='store_true')
ap.add_argument('--width', type=float, default=0.007)
ap.add_argument('--offset', type=float, default=0.012)
ap.add_argument('--report')
opt = ap.parse_args()
infos = []
if opt.all:
os.makedirs(opt.all, exist_ok=True)
files = sorted(glob.glob(os.path.join(opt.src, 't[1-6]l*.wad')))
if opt.only:
want = {w.lower().replace('.wad', '') for w in opt.only.split(',')}
files = [f for f in files if os.path.basename(f).lower()[:-4] in want]
for f in files:
info = process(f, os.path.join(opt.all, os.path.basename(f)), opt)
infos.append(info)
print('%-13s %5d KB -> %5d KB Koll %5d (unsichtbar %4d) Marker-Kacheln %3d maxV %4d' % (
info['level'], info['groesse_alt'] // 1024, info['groesse_neu'] // 1024, info.get('koll_polys', 0),
info.get('koll_unsichtbar', 0), info.get('marker_kacheln', 0), info['max_verts_modell']), flush=True)
else:
if not (opt.inp and opt.out):
ap.error('IN.wad OUT.wad oder --all OUTDIR')
info = process(opt.inp, opt.out, opt)
infos.append(info)
print(json.dumps(info, indent=1, ensure_ascii=False))
if opt.report:
json.dump(infos, open(opt.report, 'w'), indent=1, ensure_ascii=False)
if __name__ == '__main__':
main()
+55
View File
@@ -0,0 +1,55 @@
import sys, glob, os, struct
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import geomlib as G, mapchunk
# usage: debugviz_check.py <folder with debug WADs> [<folder with original CD WADs>]
SRC = (sys.argv[2] if len(sys.argv) > 2 else os.path.expanduser('~/.wine-lama/iso-dk/Setup/Eng/Cd/wads')) + '/'
bad = 0
for f in sorted(glob.glob(sys.argv[1] + '/*.wad')):
n = os.path.basename(f); err = []
ch = G.wad_read(f); o = G.wad_read(SRC + n)
wf = G.wfpc_flags(ch)
trak = G.parse_trak(G.wad_get(ch, 'TRAK'))
assert G.build_trak(trak) == G.wad_get(ch, 'TRAK')
pages, texs = G.parse_text(G.wad_get(ch, 'TEXT'))
otrak = G.parse_trak(G.wad_get(o, 'TRAK'))
nt = len(texs)
for mi, m in enumerate(trak):
if len(m.verts) > 8000: err.append('model %d %d verts' % (mi, len(m.verts)))
run = 0
for k, fc in enumerate(m.faces):
if fc['tex'] >= nt: err.append('tex idx'); break
if max(fc['v']) >= len(m.verts): err.append('vert idx'); break
run = 1 if fc['flags'] & 8 else run + 1
if run > 256: err.append('batch>256 model %d' % mi); break
if mi < len(otrak):
om = otrak[mi]
if m.coll != om.coll: err.append('KOLLISION GEAENDERT model %d' % mi)
if m.verts[:len(om.verts)] != om.verts or m.faces[:len(om.faces)] != om.faces:
err.append('Originalgeometrie geaendert %d' % mi)
elif any(len(g) for g in m.coll): err.append('neues Modell mit Kollision')
vc = [len(m.verts) for m in trak]
mp = mapchunk.parse(G.wad_get(ch, 'MAP'), wf, vc)
om = mapchunk.parse(G.wad_get(o, 'MAP'), wf, [len(m.verts) for m in otrak])
if mp['rest'] or mapchunk.build(mp, wf) != G.wad_get(ch, 'MAP'): err.append('MAP roundtrip')
for k in ('strats', 'nodes', 'list1', 'lights', 'tab32', 'grid', 'alts', 'strat_x', 'paths', 'x200', 'last'):
if mp[k] != om[k]: err.append('MAP %s geaendert' % k)
if mp['tiles'][:len(om['tiles'])] != om['tiles']: err.append('Kacheln geaendert')
W, H = mp['W'], mp['H']
for t in mp['tiles']:
if not (0 <= t[0] - 0.5 < W and 0 <= -t[2] - 0.5 < H) or t[4] >= len(trak): err.append('Kachel ausserhalb'); break
# Farbblock: je Kachel nv*frames
gm = G.parse_map_tiles(G.wad_get(ch, 'MAP'), wf, vc)
for i, fr in enumerate(gm['cols']):
if any(len(c) != vc[gm['midx'][i]] for c in fr): err.append('Farbblock %d' % i); break
# Kollision unveraendert je Kachel-Instanz der Originalkacheln
for k in ('TEXT',):
pass
others = [t for t, _ in ch if t not in ('TRAK', 'MAP ', 'TEXT')]
for t in others:
if G.wad_get(ch, t) != G.wad_get(o, t): err.append('Chunk %s geaendert' % t)
sz = sum(len(G.wad_get(ch, t)) for t in ('TRAK', 'STPC', 'MAP ', 'LGPC'))
print('%-13s %s maxV %4d maxF %4d Modelle %4d Kacheln %5d TRAK+STPC+MAP+LGPC %.1f MB' % (
n, 'OK ' if not err else 'FEHLER ' + '; '.join(sorted(set(err))[:4]), max(vc), max(len(m.faces) for m in trak),
len(trak), len(mp['tiles']), sz / 2**20))
bad += bool(err)
print('Fehler:', bad)
+11
View File
@@ -0,0 +1,11 @@
#!/usr/bin/env python3
"""Schnell-Disassembler fuer groove.exe: disx86.py VA [bytes] (VA hex, .text: file = VA-0x400000)"""
import os
import sys
from capstone import Cs, CS_ARCH_X86, CS_MODE_32
EXE = os.environ.get('GROOVE_EXE', os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 're', 'groove.exe'))
d = open(EXE, 'rb').read()
va = int(sys.argv[1], 16); n = int(sys.argv[2], 0) if len(sys.argv) > 2 else 0x400
md = Cs(CS_ARCH_X86, CS_MODE_32)
for i in md.disasm(d[va - 0x400000:va - 0x400000 + n], va):
print(f'{i.address:08x} {i.mnemonic} {i.op_str}')
+114
View File
@@ -0,0 +1,114 @@
#!/usr/bin/env python3
"""Erzeugt ein Beispiel-OBJ mit komplett neuer Geometrie fuer geom_import.py.
geom_demo.py basis.wad demo.obj [--plaza]
Baut aus Kachel-Vierecken (je 1x1, Textur = ganze Texturkachel, so wie die Engine es will):
- (--plaza) eine Bodenplatte y=0 ueber allen Rasterzellen, die im Basislevel belegt sind
(damit Strats/Startpunkt irgendwo Boden finden)
- einige Bloecke und eine Rampe in der Mitte des belegten Bereichs
Texturen: haeufigste Textur auf nach oben zeigenden Faces (Boden) bzw. auf Seitenflaechen (Wand).
"""
import argparse
import collections
import math
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import geomlib as G # noqa: E402
class ObjW:
def __init__(self, texs):
self.v, self.vt, self.f, self.texs = [], [], [], texs
def quad(self, p, tex, mat=0):
"""p: 4 Punkte, Reihenfolge so dass (p1-p0)x(p2-p0) nach aussen zeigt."""
U0, U1, V0, V1 = G.tex_uv(self.texs[tex])
uv = [(U1, V1), (U0, V1), (U0, V0), (U1, V0)]
base = len(self.v)
self.v += p
self.vt += uv
self.f.append((tex, mat, [base + k for k in range(4)]))
def box(self, x0, z0, x1, z1, y0, y1, top, side):
# Koordinaten: x0<x1, z0>z1 (z negativ = weiter im Raster)
self.quad([(x0, y1, z0), (x1, y1, z0), (x1, y1, z1), (x0, y1, z1)], top)
for (ax, az), (bx, bz) in [((x0, z0), (x0, z1)), ((x0, z1), (x1, z1)), ((x1, z1), (x1, z0)), ((x1, z0), (x0, z0))]:
# Wand von a nach b, aussen = rechts herum
n = math.ceil(y1 - y0)
for k in range(n):
ya, yb = y0 + k * (y1 - y0) / n, y0 + (k + 1) * (y1 - y0) / n
self.quad([(ax, ya, az), (bx, ya, bz), (bx, yb, bz), (ax, yb, az)][::-1], side)
def save(self, path):
with open(path, 'w') as fo:
for p in self.v:
fo.write('v %.4f %.4f %.4f 1 1 1\n' % p)
for u, v in self.vt:
fo.write('vt %.6f %.6f\n' % (u, 1 - v))
for tex, mat, ids in self.f:
fo.write(f'g demo_mat{mat:02d}\nusemtl tex_{tex}\n')
fo.write('f ' + ' '.join(f'{i + 1}/{i + 1}' for i in ids) + '\n')
def main():
ap = argparse.ArgumentParser()
ap.add_argument('base'); ap.add_argument('out'); ap.add_argument('--plaza', action='store_true')
a = ap.parse_args()
ch, wf, trak, mp = G.load_level(a.base)
pages, texs = G.parse_text(G.wad_get(ch, 'TEXT'))
up, side = collections.Counter(), collections.Counter()
for m in trak:
for f in m.faces:
t = texs[f['tex']]
if t['flags'] & 1 or t['page'] < 0:
continue
(up if f['plane'][1] > 0.9 else side if abs(f['plane'][1]) < 0.2 else collections.Counter())[f['tex']] += 1
grass, wall = up.most_common(1)[0][0], side.most_common(1)[0][0]
o = ObjW(texs)
cells = sorted(set((math.floor(t[0]), math.floor(-t[2])) for t in mp['A']))
if a.plaza:
for cx, cz in cells:
o.quad([(cx, 0, -cz), (cx + 1, 0, -cz), (cx + 1, 0, -cz - 1), (cx, 0, -cz - 1)], grass)
mx = sorted(c[0] for c in cells)[len(cells) // 2]; mz = sorted(c[1] for c in cells)[len(cells) // 2]
if not a.plaza:
# Stelle suchen, an der das 9x4-Fenster moeglichst flach und voll belegt ist
top = collections.defaultdict(lambda: -99.0)
for t in mp['A']:
if trak[t[4]].faces:
f, _ = G.tile_xform(*t[:4])
k = (math.floor(t[0]), math.floor(-t[2]))
top[k] = max(top[k], max(f(*v[:3])[1] for v in trak[t[4]].verts))
best = None
for (cx, cz) in list(top):
win = [top.get((x, z)) for x in range(cx - 4, cx + 5) for z in range(cz - 1, cz + 3)]
if None in win:
continue
spread = max(win) - min(win)
if best is None or spread < best[0]:
best = (spread, cx, cz)
if best:
mx, mz = best[1], best[2]
# Bloecke + Rampe um die Mitte; ohne --plaza auf die vorhandene Geometrie gestellt
b = 0.0
if not a.plaza:
ys = []
for t in mp['A']:
if mx - 4 <= t[0] <= mx + 5 and mz - 1 <= -t[2] <= mz + 3 and trak[t[4]].faces:
f, _ = G.tile_xform(*t[:4])
ys += [f(*v[:3])[1] for v in trak[t[4]].verts]
b = float(round(sorted(ys)[len(ys) * 3 // 4] * 4) / 4) if ys else 0.0
o.box(mx, -mz, mx + 2, -mz - 2, b, b + 1, grass, wall)
o.box(mx + 3, -mz, mx + 4, -mz - 1, b, b + 3, grass, wall)
for k in range(4): # Rampe in x-Richtung, 4 Zellen lang, steigt auf 1
y0, y1 = b + k / 4, b + (k + 1) / 4
x = mx - 4 + k
o.quad([(x, y0, -mz), (x + 1, y1, -mz), (x + 1, y1, -mz - 2), (x, y0, -mz - 2)], grass)
o.save(a.out)
print(f'{a.out}: {len(o.f)} Vierecke, Boden-Textur {grass}, Wand-Textur {wall}, Mitte Zelle ({mx},{mz})')
if __name__ == '__main__':
main()
+244
View File
@@ -0,0 +1,244 @@
#!/usr/bin/env python3
"""Level-WAD -> OBJ (+MTL, Texturseiten-PNGs, Vorschaubilder).
geom_export.py level.wad outdir [--collision] [--objects] [--no-png] [--size 1200x900]
Erzeugt in outdir:
level.obj / level.mtl Weltgeometrie (TRAK-Kacheln, platziert nach MAP), mit UVs und
Vertexfarben ("v x y z r g b"), ein OBJ-Objekt pro Kachel
page_NN.png Texturseiten (256x256, aus TEXT)
collision.obj (--collision) Kollisionspolygone, Gruppen nach Material
objects.obj (--objects) statische STPC-Objektmodelle nebeneinander
preview_top.png, preview_persp.png texturierte Software-Renderings zur Kontrolle
Koordinaten: Exportraum = MAP-Floats (y oben, rechtshaendig, z < 0). Spielraum = (x, y, -z).
Format: notes/geometry.md
"""
import argparse
import math
import os
import sys
import numpy as np
from PIL import Image
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import geomlib as G # noqa: E402
def world_tris(trak, mp, texs, use_alt=False):
"""Liefert Liste von Dreiecken: (P[3x3], UV[3x2], C[3x3] 0..1, page, texidx, tileidx)."""
out = []
for i in range(mp['N']):
x, y, z, rot, mi, fl = mp['A'][i]
m = trak[mi]
f, fn = G.tile_xform(x, y, z, rot)
W = [f(*v[:3]) for v in m.verts]
cols = mp['cols'][i][0] if mp['cols'][i] else None
for fc in m.faces:
t = texs[fc['tex']] if fc['tex'] < len(texs) else None
P = [W[k] for k in fc['v']]
if cols is not None and not (fc['flags'] & 0x4000):
C = []
for k in fc['v']:
c = cols[k]
C.append((min(255, (c & 255) * 2) / 255, min(255, (c >> 8 & 255) * 2) / 255,
min(255, (c >> 16 & 255) * 2) / 255))
else:
C = [(1, 1, 1)] * 3
if t is None or (t['flags'] & 1) or t['page'] < 0:
uv = [(0, 0)] * 3
page = -1
else:
uv = G.face_uvs(fc['flags'], G.tex_uv(t))
page = t['page']
out.append((P, uv, C, page, fc['tex'], i))
return out
def flat_color(t):
# Flache Farbe (Texturflag Bit0): u0,u1,v0 = R,G,B (Renderer FUN_00556510)
return (t['u0'] / 255, t['u1'] / 255, t['v0'] / 255)
def write_obj(path, tris, texs, mtlname):
with open(path, 'w') as fo:
fo.write('# Ein Koenigreich fuer ein Lama - Weltgeometrie (geom_export.py)\n')
fo.write(f'mtllib {mtlname}\n')
vi = 1
cur_tile = None
cur_mat = None
for P, uv, C, page, tex, tile in tris:
if tile != cur_tile:
fo.write(f'o tile_{tile:04d}\n')
cur_tile = tile
cur_mat = None
mat = f'page_{page:02d}' if page >= 0 else f'flat_{tex}'
if mat != cur_mat:
fo.write(f'usemtl {mat}\n')
cur_mat = mat
for p, c in zip(P, C):
fo.write('v %.5f %.5f %.5f %.3f %.3f %.3f\n' % (*p, *c))
for u, v in uv:
fo.write('vt %.6f %.6f\n' % (u, 1 - v))
fo.write(f'f {vi}/{vi} {vi + 1}/{vi + 1} {vi + 2}/{vi + 2}\n')
vi += 3
def write_mtl(path, tris, texs, npages):
used = sorted(set((page, tex) for _, _, _, page, tex, _ in tris), key=lambda a: (a[0], a[1]))
with open(path, 'w') as fo:
for p in range(npages):
fo.write(f'newmtl page_{p:02d}\nKd 1 1 1\nmap_Kd page_{p:02d}.png\nmap_d page_{p:02d}.png\n\n')
for page, tex in used:
if page < 0:
c = flat_color(texs[tex]) if tex < len(texs) else (1, 0, 1)
fo.write(f'newmtl flat_{tex}\nKd %.3f %.3f %.3f\n\n' % c)
# ------------------------------------------------------------ Software-Renderer
def render(tris, pages, cam, size, path, texs):
Wd, Hd = size
eye, target, fov = cam
eye = np.array(eye, float); target = np.array(target, float)
fwd = target - eye; fwd /= np.linalg.norm(fwd)
right = np.cross(fwd, [0, 1, 0]); right /= np.linalg.norm(right)
up = np.cross(right, fwd)
focal = (Wd / 2) / math.tan(math.radians(fov) / 2)
img = np.zeros((Hd, Wd, 3), np.float32); img[:] = (0.12, 0.16, 0.24)
zb = np.full((Hd, Wd), np.inf, np.float32)
pg = [p.astype(np.float32) / 255 for p in pages]
L = np.array([0.35, 0.85, 0.4]); L /= np.linalg.norm(L)
for P, uv, C, page, tex, tile in tris:
P = np.array(P) - eye
cz = P @ fwd
if (cz < 0.3).any():
continue
sx = Wd / 2 + focal * (P @ right) / cz
sy = Hd / 2 - focal * (P @ up) / cz
x0, x1 = int(max(0, math.floor(sx.min()))), int(min(Wd - 1, math.ceil(sx.max())))
y0, y1 = int(max(0, math.floor(sy.min()))), int(min(Hd - 1, math.ceil(sy.max())))
if x0 > x1 or y0 > y1:
continue
den = (sy[1] - sy[2]) * (sx[0] - sx[2]) + (sx[2] - sx[1]) * (sy[0] - sy[2])
if abs(den) < 1e-9:
continue
xs, ys = np.meshgrid(np.arange(x0, x1 + 1) + 0.5, np.arange(y0, y1 + 1) + 0.5)
w0 = ((sy[1] - sy[2]) * (xs - sx[2]) + (sx[2] - sx[1]) * (ys - sy[2])) / den
w1 = ((sy[2] - sy[0]) * (xs - sx[2]) + (sx[0] - sx[2]) * (ys - sy[2])) / den
w2 = 1 - w0 - w1
inside = (w0 >= 0) & (w1 >= 0) & (w2 >= 0)
if not inside.any():
continue
iz = w0 / cz[0] + w1 / cz[1] + w2 / cz[2]
depth = 1 / np.where(inside, iz, 1e-9)
sub = zb[y0:y1 + 1, x0:x1 + 1]
# Farbe
nrm = np.cross(np.array(P[1]) - P[0], np.array(P[2]) - P[0])
nl = np.linalg.norm(nrm)
shade = 0.55 + 0.45 * abs(nrm @ L) / nl if nl > 0 else 1
Cc = np.array(C)
if page >= 0:
UV = np.array(uv)
pu = (w0 / cz[0] * UV[0, 0] + w1 / cz[1] * UV[1, 0] + w2 / cz[2] * UV[2, 0]) * depth
pv = (w0 / cz[0] * UV[0, 1] + w1 / cz[1] * UV[1, 1] + w2 / cz[2] * UV[2, 1]) * depth
tp = pg[page]
tx = np.clip((pu * tp.shape[1]).astype(int), 0, tp.shape[1] - 1)
ty = np.clip((pv * tp.shape[0]).astype(int), 0, tp.shape[0] - 1)
texel = tp[ty, tx]
alpha = texel[..., 3] > 0.5
col = texel[..., :3]
else:
t = texs[tex] if tex < len(texs) else None
col = np.broadcast_to(np.array(flat_color(t) if t else (1, 0, 1), np.float32), xs.shape + (3,))
alpha = np.ones(xs.shape, bool)
vc = (w0[..., None] * Cc[0] + w1[..., None] * Cc[1] + w2[..., None] * Cc[2])
col = col * vc * shade
mask = inside & alpha & (depth < sub)
sub[mask] = depth[mask]
img[y0:y1 + 1, x0:x1 + 1][mask] = col[mask]
Image.fromarray((np.clip(img, 0, 1) * 255).astype(np.uint8)).save(path)
def main():
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument('wad'); ap.add_argument('outdir')
ap.add_argument('--collision', action='store_true')
ap.add_argument('--objects', action='store_true')
ap.add_argument('--no-png', action='store_true', help='keine Seiten-PNGs/Vorschau')
ap.add_argument('--size', default='1200x900')
a = ap.parse_args()
os.makedirs(a.outdir, exist_ok=True)
ch, wf, trak, mp = G.load_level(a.wad)
pages, texs = G.parse_text(G.wad_get(ch, 'TEXT'))
tris = world_tris(trak, mp, texs)
write_obj(os.path.join(a.outdir, 'level.obj'), tris, texs, 'level.mtl')
write_mtl(os.path.join(a.outdir, 'level.mtl'), tris, texs, len(pages))
print(f'{a.wad}: WFPC={wf:#x} TRAK-Modelle={len(trak)} Kacheln={mp["N"]} Raster={mp["W"]}x{mp["H"]} '
f'Dreiecke={len(tris)} Texturen={len(texs)} Seiten={len(pages)}')
if a.collision:
with open(os.path.join(a.outdir, 'collision.obj'), 'w') as fo:
vi = 1
for i in range(mp['N']):
x, y, z, rot, mi, fl = mp['A'][i]
f, _ = G.tile_xform(x, y, z, rot)
for g, grp in enumerate(trak[mi].coll):
for r in grp:
c = G.coll_decode(r)
pts = G.coll_polygon(c)
if len(pts) < 3:
continue
nrm = np.cross(np.subtract(pts[1], pts[0]), np.subtract(pts[2], pts[0]))
if nrm @ np.array(c['plane'][:3]) < 0:
pts = pts[::-1]
fo.write(f'g mat{c["mat"]:02d}_grp{g}\n')
for p in pts:
fo.write('v %.5f %.5f %.5f\n' % f(*p))
fo.write('f ' + ' '.join(str(vi + k) for k in range(len(pts))) + '\n')
vi += len(pts)
if a.objects:
models, _ = G.parse_stpc_models(G.wad_get(ch, 'STPC'))
with open(os.path.join(a.outdir, 'objects.obj'), 'w') as fo:
fo.write('mtllib level.mtl\n')
vi = 1; vt = 1; ox = 0.0
for k, m in enumerate(models):
if not m.verts:
continue
xs = [v[0] for v in m.verts]
dx = ox - min(xs)
fo.write(f'o stpc_model_{k:03d}\n')
for v in m.verts:
fo.write('v %.5f %.5f %.5f\n' % (v[0] + dx, v[1], v[2]))
for fc in m.faces:
t = texs[fc['tex']] if fc['tex'] < len(texs) else None
if t and not (t['flags'] & 1) and t['page'] >= 0:
uv = G.face_uvs(fc['flags'], G.tex_uv(t)); fo.write(f'usemtl page_{t["page"]:02d}\n')
else:
uv = [(0, 0)] * 3; fo.write(f'usemtl flat_{fc["tex"]}\n')
for u, v in uv:
fo.write('vt %.6f %.6f\n' % (u, 1 - v))
fo.write('f ' + ' '.join(f'{vi + fc["v"][j]}/{vt + j}' for j in range(3)) + '\n')
vt += 3
vi += len(m.verts)
ox += max(xs) - min(xs) + 0.5
if not a.no_png:
imgs = [G.page_rgba(p) for p in pages]
for k, im in enumerate(imgs):
Image.fromarray(im).save(os.path.join(a.outdir, f'page_{k:02d}.png'))
size = tuple(int(s) for s in a.size.split('x'))
P = np.array([p for t in tris for p in t[0]])
lo, hi = P.min(0), P.max(0); c = (lo + hi) / 2
ext = max(hi[0] - lo[0], hi[2] - lo[2])
# Draufsicht (fast senkrecht, Norden = -z oben)
render(tris, imgs, ((c[0], hi[1] + ext * 1.2, c[2] + 0.01), (c[0], lo[1], c[2]), 50), size,
os.path.join(a.outdir, 'preview_top.png'), texs)
render(tris, imgs, ((c[0] + ext * 0.35, hi[1] + ext * 0.45, hi[2] + ext * 0.45), tuple(c), 60), size,
os.path.join(a.outdir, 'preview_persp.png'), texs)
if __name__ == '__main__':
main()
+274
View File
@@ -0,0 +1,274 @@
#!/usr/bin/env python3
"""OBJ -> neue Weltgeometrie (TRAK + Kachelteil von MAP) in einer Kopie eines Level-WADs.
geom_import.py basis.wad mesh.obj neu.wad [--default-tex N] [--no-coll] [--mat N]
[--keep-tiles] [--band 6]
Eingabe-OBJ im Exportraum von geom_export.py (y oben, 1 Einheit = 1 Kachel, z < 0 liegt im
Raster; Rasterzelle (cx,cz) = (floor(x), floor(-z)), muss in 0..W-1 / 0..H-1 liegen).
Texturen: Die Engine kennt KEINE freien UVs. Jedes Dreieck zeigt eine Haelfte eines
Texturrechtecks (Eintrag der TEXT-Tabelle), die Ecken werden ueber Face-Flags gewaehlt.
usemtl page_NN -> Texturindex wird aus dem UV-Rechteck (Bounding-Box der Face-UVs)
auf Seite NN gesucht; UVs muessen auf Rechteck-Ecken liegen
(so wie geom_export sie schreibt).
usemtl tex_N -> Texturindex N; UVs (falls vorhanden) waehlen nur die Ecken-Belegung,
ohne UVs wird die Standardbelegung genommen.
usemtl flat_N -> Texturindex N (so exportiert geom_export Flachfarben)
sonst -> --default-tex
Vierecke werden als (0,1,2)+(2,3,0) zerlegt; mit Rechteck-UVs ergibt das genau die
Original-Belegung (Flags 0x800 / 0x810).
Vertexfarben: "v x y z r g b" (0..1, 1.0 = Originalhelligkeit 0x80); sonst 0x80.
Kollision: --coll-obj datei.obj (Polygone, Gruppen "matNN...") oder aus jedem Face ein konvexes Kollisionspolygon (Viereck wenn planar), eingetragen
in jede Rasterzelle, die das Polygon (xz-Bounding-Box) beruehrt. Material: Gruppe/Objekt
"..._matNN" bzw. --mat. (--no-coll: keine Kollision)
Ausgabe: Ein Modell pro belegter (Zelle, Hoehenband). Alle vorhandenen Kacheln werden ersetzt
(ausser --keep-tiles: alte Kacheln + Modelle bleiben, neue kommen dazu).
Strats/Pfade/Zonen (Rest von MAP) und alle anderen Chunks bleiben unveraendert.
"""
import argparse
import math
import os
import re
import struct
import sys
from collections import defaultdict
import numpy as np
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import geomlib as G # noqa: E402
PATTERNS = [0x800, 0x810, 0x820, 0x830, 0x000, 0x020]
def read_obj(path):
V, C, VT = [], [], []
faces = [] # (idx list, vt list or None, material, group)
mat, grp = None, ''
for line in open(path, encoding='latin1'):
p = line.split()
if not p:
continue
if p[0] == 'v':
V.append(tuple(float(x) for x in p[1:4]))
C.append(tuple(float(x) for x in p[4:7]) if len(p) >= 7 else None)
elif p[0] == 'vt':
VT.append((float(p[1]), float(p[2])))
elif p[0] == 'usemtl':
mat = p[1] if len(p) > 1 else None
elif p[0] in ('o', 'g'):
grp = p[1] if len(p) > 1 else ''
elif p[0] == 'f':
vi, ti = [], []
for tok in p[1:]:
s = tok.split('/')
a = int(s[0]); vi.append(a - 1 if a > 0 else len(V) + a)
if len(s) > 1 and s[1]:
b = int(s[1]); ti.append(b - 1 if b > 0 else len(VT) + b)
faces.append((vi, ti if len(ti) == len(vi) else None, mat, grp))
return V, C, VT, faces
def find_tex(texs, page, uvs):
"""Texturindex auf Seite 'page', dessen Rechteck am besten zu den UVs passt."""
us = [u * 256 for u, v in uvs]; vs = [v * 256 for u, v in uvs]
best, bd = None, 1e9
for i, t in enumerate(texs):
if t['page'] != page or (t['flags'] & 1):
continue
d = (abs(t['u0'] - min(us)) + abs(t['u1'] + 1 - max(us)) + abs(t['v0'] - min(vs)) + abs(t['v1'] + 1 - max(vs)))
if d < bd:
best, bd = i, d
return best, bd
def match_pattern(uvs, rect):
"""Beste (Flags, Rotation) so dass face_uvs(flags) die gegebenen Ecken-UVs trifft."""
best = (1e9, 0x800, 0)
for fl in PATTERNS:
ref = G.face_uvs(fl, rect)
for rot in range(3):
r = [uvs[(k + rot) % 3] for k in range(3)]
d = sum(abs(a[0] - b[0]) + abs(a[1] - b[1]) for a, b in zip(r, ref))
if d < best[0]:
best = (d, fl, rot)
return best[1], best[2], best[0]
def color_u32(c):
if c is None:
return 0x01808080
r, g, b = (max(0, min(255, int(round(x * 128)))) for x in c)
return (1 << 24) | (b << 16) | (g << 8) | r
def main():
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument('base'); ap.add_argument('obj'); ap.add_argument('out')
ap.add_argument('--default-tex', type=int, default=None)
ap.add_argument('--mat', type=int, default=0, help='Kollisionsmaterial (Standard 0)')
ap.add_argument('--no-coll', action='store_true')
ap.add_argument('--coll-obj', help='Kollision aus eigenem OBJ (z.B. collision.obj von geom_export) '
'statt aus den Render-Faces')
ap.add_argument('--keep-tiles', action='store_true')
ap.add_argument('--zones', choices=['flat', 'keep'], default='flat',
help='flat: Zonen-Sichtbarkeit (PVS) abschalten, alles zeichnen; keep: Original')
ap.add_argument('--band', type=float, default=6.0, help='Hoehenband pro Kachel (Einheiten)')
a = ap.parse_args()
ch, wf, trak, mp = G.load_level(a.base)
pages, texs = G.parse_text(G.wad_get(ch, 'TEXT'))
W, H = mp['W'], mp['H']
V, C, VT, faces = read_obj(a.obj)
deftex = a.default_tex
if deftex is None:
deftex = next(i for i, t in enumerate(texs) if not (t['flags'] & 1) and t['page'] >= 0)
# ---- Dreiecke mit Textur + Flags
tris = [] # (pos[3], col[3], tex, flags)
polys = [] # (pos list, mat) fuer Kollision
warn = defaultdict(int)
for vi, ti, mat, grp in faces:
pts = [V[i] for i in vi]; cols = [C[i] for i in vi]
uvs = [(VT[i][0], 1 - VT[i][1]) for i in ti] if ti else None
m = re.search(r'mat(\d+)', grp or '')
polys.append((pts, int(m.group(1)) if m else a.mat))
tex = None
mm = re.match(r'(page|tex|flat)_(\d+)$', mat or '')
if mm and mm.group(1) == 'page' and uvs:
tex, dist = find_tex(texs, int(mm.group(2)), uvs)
if tex is None or dist > 2:
warn['UV-Rechteck nicht gefunden'] += 1
elif mm:
tex = int(mm.group(2))
if tex is None:
tex = deftex
t = texs[tex]
rect = G.tex_uv(t)
for k in range(1, len(vi) - 1):
idx = [0, k, k + 1]
P = [pts[j] for j in idx]; Cc = [cols[j] for j in idx]
if uvs and not (t['flags'] & 1):
fl, rot, d = match_pattern([uvs[j] for j in idx], rect)
if d > 2.5 / 256:
warn['UVs nicht auf Rechteck-Ecken'] += 1
P = [P[(j + rot) % 3] for j in range(3)]; Cc = [Cc[(j + rot) % 3] for j in range(3)]
else:
fl = 0x800 if k % 2 else 0x810
tris.append((P, Cc, tex, fl))
if a.coll_obj:
cV, _, _, cfaces = read_obj(a.coll_obj)
polys = []
for vi, ti, mat, grp in cfaces:
m = re.search(r'mat(\d+)', grp or '')
polys.append(([cV[i] for i in vi], int(m.group(1)) if m else a.mat))
# ---- Zellen/Baender
def key_of(x, y, z):
cx, cz = math.floor(x), math.floor(-z)
return (cx, cz, math.floor(y / a.band))
cells = defaultdict(lambda: dict(tris=[], coll=[]))
for tr in tris:
cen = np.mean(tr[0], axis=0)
cells[key_of(*cen)]['tris'].append(tr)
if not a.no_coll:
for pts, mat in polys:
P = np.array(pts)
cen = P.mean(0)
band = math.floor(cen[1] / a.band)
for cx in range(math.floor(P[:, 0].min()), math.floor(P[:, 0].max() - 1e-6) + 1):
for cz in range(math.floor(-P[:, 2].max()), math.floor(-P[:, 2].min() - 1e-6) + 1):
cells[(cx, cz, band)]['coll'].append((pts, mat))
# ---- Modelle bauen
models, tiles = [], []
if a.keep_tiles:
models = list(trak)
for i in range(mp['N']):
x, y, z, rot, mi, fl = mp['A'][i]
tiles.append(dict(x=x, y=y, z=z, rot=rot, model=mi, cols=mp['cols'][i], flag=fl,
altcols=mp['altcols'].get(i, [])))
for (cx, cz, band), cell in sorted(cells.items()):
if not (0 <= cx < W and 0 <= cz < H):
warn[f'ausserhalb des Rasters {W}x{H}'] += 1
continue
allp = [p for t in cell['tris'] for p in t[0]] or [p for c in cell['coll'] for p in c[0]]
ty = float(round(np.median([p[1] for p in allp])))
ox, oz = cx + 0.5, -(cz + 0.5)
m = G.Model(); m.dirty = True
vmap, cols = {}, []
facelist = []
for P, Cc, tex, fl in sorted(cell['tris'], key=lambda t: (texs[t[2]]['page'], texs[t[2]]['flags'], t[2])):
ids = []
for p, c in zip(P, Cc):
lp = (round(p[0] - ox, 5), round(p[1] - ty, 5), round(p[2] - oz, 5))
k = (lp, color_u32(c))
if k not in vmap:
vmap[k] = len(m.verts); m.verts.append([*lp, 0.0, 0.0, 0.0]); cols.append(k[1])
ids.append(vmap[k])
if len(set(ids)) < 3:
continue
facelist.append((ids, tex, fl))
prev = None
nacc = np.zeros((len(m.verts), 3))
for ids, tex, fl in facelist:
plane = G.face_plane(*[m.verts[i][:3] for i in ids])
st = (texs[tex]['page'], texs[tex]['flags'])
# Bit 0x8 = neuer Render-Batch (Zustand neu laden). Batch-Puffer 0x5d15f8 fasst ~4800
# Ausgabe-Vertices -> spaetestens alle 256 Faces neu starten.
if st != prev or len(m.faces) % 256 == 0:
fl |= 0x8
prev = st
m.faces.append(dict(flags=fl, v=tuple(ids), tex=tex, pad=0, plane=plane))
for i in ids:
nacc[i] += plane[:3]
for i, v in enumerate(m.verts):
n = nacc[i]; ln = np.linalg.norm(n)
v[3:6] = (n / ln).tolist() if ln > 0 else [0.0, 1.0, 0.0]
m.verts = [tuple(v) for v in m.verts]
for pts, mat in cell['coll']:
loc = [(p[0] - ox, p[1] - ty, p[2] - oz) for p in pts]
if max(abs(c) for p in loc for c in p) > 7.9:
warn['Kollisionspolygon > 7.9 Einheiten vom Kachelzentrum (uebersprungen)'] += 1
continue
recs = []
if len(loc) == 4:
r = G.coll_from_polygon(loc, mat)
n = np.cross(np.subtract(loc[1], loc[0]), np.subtract(loc[2], loc[0]))
if r is not None and abs(np.dot(n / (np.linalg.norm(n) or 1), np.subtract(loc[3], loc[0]))) < 1e-3:
recs = [r]
if not recs:
for k in range(1, len(loc) - 1):
r = G.coll_from_polygon([loc[0], loc[k], loc[k + 1]], mat)
if r is not None:
recs.append(r)
for r in recs:
ny = struct.unpack_from('<b', r, 3)[0]
if ny < 0:
r = bytes([r[0] | 4]) + r[1:]
m.coll[0].append(r)
if len(m.verts) > 8000 or len(m.faces) > 65535:
sys.exit('Kachel zu gross (Transformpuffer 0x58b0f0 fasst 8000 Vertices)')
models.append(m)
tiles.append(dict(x=ox, y=ty, z=oz, rot=0, model=len(models) - 1, cols=[cols]))
if not models:
sys.exit('keine Geometrie')
newtrak = G.build_trak(models)
newmap = G.build_map_tiles(G.wad_get(ch, 'MAP'), wf, [len(m.verts) for m in trak], tiles, a.zones,
mp['anim'] if a.keep_tiles else ())
G.wad_set(ch, 'TRAK', newtrak)
G.wad_set(ch, 'MAP', newmap)
G.wad_write(a.out, ch)
nf = sum(len(m.faces) for m in models); nc = sum(len(m.coll[0]) for m in models)
print(f'{a.out}: {len(models)} Modelle, {len(tiles)} Kacheln, {nf} Dreiecke, {nc} Kollisionspolygone')
for k, v in warn.items():
print(f' WARNUNG: {k}: {v}x')
if __name__ == '__main__':
main()
+429
View File
@@ -0,0 +1,429 @@
#!/usr/bin/env python3
"""Geometrie-Bibliothek fuer Level-WADs von "Ein Koenigreich fuer ein Lama" (Croc-2-Engine, PC).
Format siehe notes/geometry.md. Kurz:
TRAK = Kachel-Modelle (Welt-Geometrie + Kollision), u32 n, n x 0x84 Header, dann Daten
MAP = Kachel-Raster: Platzierung (Position/Rotation/Modell) + Vertexfarben (+ Strats usw.)
STPC = Objekt-Modelle (gleiches Modellformat, Header 0x8c) + Animationen + Strat-Skripte
TEXT = Texturseiten + Texturtabelle (UV-Rechtecke)
Alle Parser sind aus groove.exe nachgebaut (FUN_005563f0 TRAK, FUN_0041f770 STPC-Modell,
FUN_0042ac50 MAP, FUN_00406d30 Texturtabelle, FUN_00556510 Kachel-Renderer).
"""
import math
import struct
# ---------------------------------------------------------------- WAD
def wad_read(path):
d = open(path, 'rb').read()
total = struct.unpack_from('<I', d, 0)[0]
assert total == len(d) - 4, 'WAD-Laengenfeld passt nicht'
chunks, o = [], 4
while o + 8 <= len(d):
tag = d[o:o + 4][::-1].decode('latin1')
ln = struct.unpack_from('<I', d, o + 4)[0]
chunks.append([tag, d[o + 8:o + 8 + ln]])
o += 8 + ln
if tag == 'END ':
break
return chunks
def wad_write(path, chunks):
body = b''.join(t.encode('latin1')[::-1] + struct.pack('<I', len(c)) + c for t, c in chunks)
open(path, 'wb').write(struct.pack('<I', len(body)) + body)
def wad_get(chunks, tag):
for t, c in chunks:
if t == tag.ljust(4):
return c
return None
def wad_set(chunks, tag, data):
for c in chunks:
if c[0] == tag.ljust(4):
c[1] = data
return
raise KeyError(tag)
def wfpc_flags(chunks):
c = wad_get(chunks, 'WFPC')
return struct.unpack('<I', c)[0] if c else 0
# ---------------------------------------------------------------- Modelle
# Header (TRAK 0x84 Byte, STPC 0x8c Byte):
# +0x00 3f Mittelpunkt der Bounding-Box
# +0x0c 24f 8 Ecken der (um 1/128 vergroesserten) Bounding-Box
# +0x6c u16 nVerts +0x6e u16 nFaces
# +0x70 u32 (Zeiger Verts, in Datei 0) +0x74 u32 (Zeiger Faces, 0)
# +0x78 u16 nColA +0x7a u16 nColB +0x7c u16 nColC (Kollisionspolygone, 32 Byte)
# +0x7e u16 0 +0x80 u32 (Zeiger Kollision, 0)
# nur STPC: +0x84 u16 ?(meist = nVerts oder 0) +0x86 u16 nX (n*2 Byte Daten vor den Verts)
# +0x88 u32 (Zeiger, 0)
# Daten: [STPC: nX*u16] Verts nV*24 (pos 3f, normal 3f), Faces nF*28, Kollision nCol*32
# Face (28): u16 flags, u16 v0,v1,v2, u16 tex, u16 0, 3f Ebenen-Normale, f d (n.v = d)
# Kollision (32): u8 typ (Bit0: Viereck, sonst Dreieck), u8 Material, 5 x {i8 nx,ny,nz, u8 0,
# i16 d}: Ebene 0 = Flaeche, Ebenen 1..4 = Kanten (nach innen). Normalen *127, d *4096,
# Koordinaten im Spielraum (z gespiegelt ggue. Vertexdaten!).
CORNERS = [(-1, -1, 1), (1, -1, 1), (-1, 1, 1), (1, 1, 1), (-1, -1, -1), (1, -1, -1), (-1, 1, -1), (1, 1, -1)]
class Model:
def __init__(self):
self.verts = [] # [(x,y,z,nx,ny,nz)]
self.faces = [] # [dict(flags,v=(a,b,c),tex,pad,plane=(nx,ny,nz,d))]
self.coll = [[], [], []] # drei Gruppen; je Eintrag 32 Byte roh (bytes)
self.hdr = None # Original-Header (bytes) zum Vergleich
self.extra = b'' # STPC: nX*u16
self.x84 = 0
self.dirty = False # True setzen, wenn Verts geaendert -> Bounding-Box neu
def _parse_models(d, o, n, hsize):
hdr0 = o
o += n * hsize
out = []
for i in range(n):
b = hdr0 + i * hsize
m = Model()
m.hdr = d[b:b + hsize]
m._nv0 = struct.unpack_from('<H', d, b + 0x6c)[0]
nv, nf = struct.unpack_from('<HH', d, b + 0x6c)
nc = struct.unpack_from('<3H', d, b + 0x78)
if hsize == 0x8c:
m.x84, nx = struct.unpack_from('<HH', d, b + 0x84)
m.extra = d[o:o + nx * 2]
o += nx * 2
for k in range(nv):
m.verts.append(struct.unpack_from('<6f', d, o)); o += 24
for k in range(nf):
fl, a, b_, c, tex, pad = struct.unpack_from('<6H', d, o)
m.faces.append(dict(flags=fl, v=(a, b_, c), tex=tex, pad=pad, plane=struct.unpack_from('<4f', d, o + 12)))
o += 28
for g in range(3):
for k in range(nc[g]):
m.coll[g].append(d[o:o + 32]); o += 32
out.append(m)
return out, o
def parse_stpc_models(d):
"""Nur die statischen Objektmodelle am Anfang von STPC (u32 n, je 1 Modell mit 0x8c-Header)."""
n = struct.unpack_from('<I', d, 0)[0]
o = 4
out = []
for i in range(n):
ms, o = _parse_models(d, o, 1, 0x8c)
out += ms
return out, o
def parse_trak(d):
n = struct.unpack_from('<I', d, 0)[0]
models, o = _parse_models(d, 4, n, 0x84)
assert o == len(d), f'TRAK: {o} != {len(d)}'
return models
def bbox_header(m):
if m.verts:
xs = [v[0] for v in m.verts]; ys = [v[1] for v in m.verts]; zs = [v[2] for v in m.verts]
lo = (min(xs), min(ys), min(zs)); hi = (max(xs), max(ys), max(zs))
else:
lo = hi = (0.0, 0.0, 0.0)
c = tuple((a + b) / 2 for a, b in zip(lo, hi))
e = 1 / 128 # Original vergroessert die Box um 1/128
h = tuple((b - a) / 2 + e for a, b in zip(lo, hi))
pts = []
for s in CORNERS:
pts += [c[k] + s[k] * h[k] for k in range(3)]
return c, pts
def build_models(models, hsize, recompute_bbox=None):
"""recompute_bbox=None: Original-Box behalten, wenn vorhanden und Verts unveraendert."""
hdr, data = b'', b''
for m in models:
if recompute_bbox is False or (recompute_bbox is None and m.hdr is not None
and getattr(m, '_nv0', None) == len(m.verts) and not getattr(m, 'dirty', False)):
h = m.hdr[:0x6c]
else:
c, pts = bbox_header(m)
h = struct.pack('<3f24f', *c, *pts)
h += struct.pack('<HHII3HHI', len(m.verts), len(m.faces), 0, 0, *(len(g) for g in m.coll), 0, 0)
if hsize == 0x8c:
h += struct.pack('<HHI', m.x84, len(m.extra) // 2, 0)
data += m.extra
assert len(h) == hsize
hdr += h
for v in m.verts:
data += struct.pack('<6f', *v)
for f in m.faces:
data += struct.pack('<6H4f', f['flags'], *f['v'], f['tex'], f.get('pad', 0), *f['plane'])
for g in m.coll:
for r in g:
data += r
return hdr + data
def build_trak(models):
return struct.pack('<I', len(models)) + build_models(models, 0x84)
def face_plane(p0, p1, p2):
ax, ay, az = (p1[i] - p0[i] for i in range(3))
bx, by, bz = (p2[i] - p0[i] for i in range(3))
# Wicklung: Original-Normale = (v1-v0) x (v2-v0)
nx, ny, nz = ay * bz - az * by, az * bx - ax * bz, ax * by - ay * bx
ln = math.sqrt(nx * nx + ny * ny + nz * nz) or 1.0
nx, ny, nz = nx / ln, ny / ln, nz / ln
return (nx, ny, nz, nx * p0[0] + ny * p0[1] + nz * p0[2])
# ---------------------------------------------------------------- Kollision
def coll_decode(r):
"""32-Byte-Kollisionspolygon -> dict (Ebenen im Vertex-/Exportraum, d.h. z zurueckgespiegelt)."""
typ, mat = r[0], r[1]
planes = []
for k in range(5):
nx, ny, nz, pad, d = struct.unpack_from('<bbbBh', r, 2 + 6 * k)
planes.append((nx / 127, ny / 127, -nz / 127, d / 4096))
nedge = 4 if typ & 1 else 3
return dict(typ=typ, mat=mat, plane=planes[0], edges=planes[1:1 + nedge])
def coll_polygon(c):
"""Eckpunkte aus Flaechenebene + Kantenebenen (Schnitt benachbarter Kanten)."""
import numpy as np
P = c['plane']; E = c['edges']
pts = []
n = len(E)
for i in range(n):
a, b = E[i], E[(i + 1) % n]
A = np.array([P[:3], a[:3], b[:3]]); B = np.array([P[3], a[3], b[3]])
try:
pts.append(tuple(np.linalg.solve(A, B)))
except np.linalg.LinAlgError:
return []
return pts
def coll_encode(typ, mat, plane, edges):
"""Ebenen im Vertexraum -> 32 Byte (z wird fuer den Spielraum gespiegelt)."""
def q(p):
nx, ny, nz, d = p
cl = lambda v: max(-127, min(127, int(round(v * 127))))
return struct.pack('<bbbBh', cl(nx), cl(ny), cl(-nz), 0, max(-32768, min(32767, int(round(d * 4096)))))
out = bytes([typ, mat]) + q(plane) + b''.join(q(e) for e in edges)
return out.ljust(32, b'\0')
def coll_from_polygon(pts, mat=0, typ=None):
"""Konvexes Polygon (3 oder 4 Punkte, Vertexraum, Reihenfolge wie Faces) -> 32-Byte-Record."""
import numpy as np
P = [np.array(p, float) for p in pts]
n = np.cross(P[1] - P[0], P[2] - P[0])
if np.linalg.norm(n) < 1e-9:
return None
n /= np.linalg.norm(n)
d = float(n @ P[0])
edges = []
cen = sum(P) / len(P)
for i in range(len(P)):
a, b = P[i], P[(i + 1) % len(P)]
e = np.cross(n, b - a)
if np.linalg.norm(e) < 1e-9:
return None
e /= np.linalg.norm(e)
if e @ (cen - a) < 0:
e = -e # nach innen
edges.append((*e, float(e @ a)))
if typ is None:
typ = 1 if len(P) == 4 else 0
return coll_encode(typ, mat, (*n, d), edges)
# ---------------------------------------------------------------- MAP (nur Kachelteile)
STRAT_REC = 90 # Bytes pro Eintrag des 2. Blocks (0x5c im Speicher)
NODE_REC = 34 # Bytes pro Eintrag des 3. Blocks (0x30 im Speicher)
def parse_map_tiles(d, wfpc, vcounts):
"""Liefert Kachel-Infos + Byte-Offsets der Abschnitte (fuer Splicing)."""
N, W, H = struct.unpack_from('<3I', d, 0)
o = 12
offs = {'A': o}
A = [struct.unpack_from('<3f3I', d, o + 24 * i) for i in range(N)]
o += 24 * N
offs['A_end'] = o
nl = struct.unpack_from('<I', d, o)[0]; o += 4 + 4 * nl
ns = struct.unpack_from('<I', d, o)[0]; o += 4 + ns * STRAT_REC
nn = struct.unpack_from('<I', d, o)[0]; o += 4 + nn * NODE_REC
o += 256
offs['grid'] = o
zone = struct.unpack_from('<%dI' % (W * H), d, o); o += 4 * W * H
offs['B'] = o
B = [struct.unpack_from('<6i', d, o + 24 * i) for i in range(N)]; o += 24 * N
midx = list(struct.unpack_from('<%dI' % N, d, o)); o += 4 * N
anim = []
if wfpc & 0x10000:
na = struct.unpack_from('<I', d, o)[0]; o += 4
anim = [struct.unpack_from('<5I', d, o + 20 * i) for i in range(na)]; o += 20 * na
offs['col'] = o
flagged = [i for i in range(N) if A[i][5]]
altmap = dict(zip(flagged, anim))
cols, altcols = [], {}
for i in range(N):
for k, mdl in enumerate([midx[i]] + ([altmap[i][4]] if i in altmap else [])):
nv = vcounts[mdl]
c = list(struct.unpack_from('<%dI' % nv, d, o)); o += 4 * nv
fr = max([x >> 24 for x in c] + [0])
frames = [c]
for f in range(1, fr):
frames.append(list(struct.unpack_from('<%dI' % nv, d, o))); o += 4 * nv
if k == 0:
cols.append(frames)
else:
altcols[i] = frames
offs['col_end'] = o
return dict(N=N, W=W, H=H, A=A, B=B, midx=midx, anim=anim, zone=zone, cols=cols,
altcols=altcols, offs=offs)
def build_map_tiles(d, wfpc, old_vcounts, tiles, zones='flat', anim=()):
"""Ersetzt die Kachelabschnitte im MAP-Chunk.
tiles: Liste dict(x,y,z,rot,model,cols=[[rgba u32]*nv]) ; x/y/z in Kacheleinheiten
(Exportraum: y oben, z<=0), rot in 4096tel (Rotation um y).
Kachel-Wechsel-Animationen: tiles[i]['flag']=1 + tiles[i]['altcols'] (Farben des Alt-Modells);
anim = Liste der 5-u32-Eintraege (Zelle x, y, z, Modell, Alt-Modell), in Reihenfolge der
geflaggten Kacheln. Ohne anim werden alle Flags geloescht.
zones='flat': Sichtbarkeitszonen abschalten (Zonenraster = 0, Maske von Zone 0 = alle),
sonst ('keep') Zonenraster + 32er-Tabelle unveraendert lassen.
Alles andere (Strat-/Knoten-/Lichtbloecke) bleibt unveraendert.
"""
m = parse_map_tiles(d, wfpc, old_vcounts)
of = m['offs']
N = len(tiles)
head = struct.pack('<3I', N, m['W'], m['H'])
A = b''.join(struct.pack('<3f3I', t['x'], t['y'], t['z'], t['rot'] & 0xfff, t['model'],
1 if (anim and t.get('flag')) else 0) for t in tiles)
B = b''.join(struct.pack('<6i', 0, t['rot'] & 0xfff, 0, int(round(t['x'] * 4096)),
int(round(t['y'] * 4096)), int(round(-t['z'] * 4096))) for t in tiles)
midx = struct.pack('<%dI' % N, *[t['model'] for t in tiles])
if anim and sum(1 for t in tiles if t.get('flag')) != len(anim):
raise ValueError('Anzahl geflaggter Kacheln != Anzahl anim-Eintraege')
animb = (struct.pack('<I', len(anim)) + b''.join(struct.pack('<5I', *e) for e in anim)) if wfpc & 0x10000 else b''
cols = b''
for t in tiles:
for frame in t['cols'] + (t['altcols'] if (anim and t.get('flag')) else []):
cols += struct.pack('<%dI' % len(frame), *frame)
mid = d[of['A_end']:of['B']]
if zones == 'flat':
g = of['grid'] - of['A_end'] # Offset der Zonentabelle+Raster innerhalb mid
tab = struct.pack('<2I', 0xffffffff, struct.unpack_from('<I', mid, g - 256 + 4)[0]) + mid[g - 248:g]
mid = mid[:g - 256] + tab + bytes(4 * m['W'] * m['H'])
return head + A + mid + B + midx + animb + cols + d[of['col_end']:]
# ---------------------------------------------------------------- TEXT (Texturtabelle + Seiten)
def parse_text(d):
o = 0
npal = struct.unpack_from('<I', d, o)[0]; o += 4
for _ in range(npal):
n = struct.unpack_from('<I', d, o + 4)[0]; o += 8 + 3 * n
npg = struct.unpack_from('<I', d, o)[0]; o += 4
pages = []
for _ in range(npg):
fl, w, h = struct.unpack_from('<3I', d, o); o += 12
if fl == 0:
sz = w * h; raw = d[o:o + sz]; comp = False
elif fl & 0x80:
sz = struct.unpack_from('<I', d, o)[0]; o += 4; raw = d[o:o + sz]; comp = True
else:
sz = w * h * 2; raw = d[o:o + sz]; comp = False
o += sz
pages.append(dict(flags=fl, w=w, h=h, raw=raw, comp=comp))
nt = struct.unpack_from('<I', d, o)[0]; o += 4
texs = []
for i in range(nt):
fl, pg, p2, u0, u1, v0, v1 = struct.unpack_from('<HbbBBBB', d, o); o += 8
texs.append(dict(flags=fl, page=pg, page2=p2, u0=u0, u1=u1, v0=v0, v1=v1))
return pages, texs
def page_rgba(p):
"""16-Bit-Seite -> numpy RGBA (Annahme: ABGR1555 wie PS1, RLE16 bei flags&0x80)."""
import numpy as np
w, h = p['w'], p['h']
if p['flags'] == 0:
px = np.frombuffer(p['raw'], np.uint8).astype(np.uint16) * 0x421 # Graustufen-Notloesung
elif p['comp']:
v = np.frombuffer(p['raw'], '<u2'); out = np.zeros(w * h, np.uint16)
i = q = 0
while q < w * h and i < len(v):
n = int(v[i]); i += 1
if n & 0x8000:
n -= 0x10000
if n > 0:
out[q:q + n] = v[i:i + n]; i += n; q += n
elif n < 0:
out[q:q - n] = v[i]; i += 1; q -= n
px = out
else:
px = np.frombuffer(p['raw'], '<u2')
px = px[:w * h].reshape(h, w)
r = (px & 31) << 3; g = ((px >> 5) & 31) << 3; b = ((px >> 10) & 31) << 3
a = np.where(px == 0, 0, 255)
return np.dstack([r, g, b, a]).astype(np.uint8)
def tex_uv(t, pw=256, ph=256):
"""UV-Rechteck (U0,U1,V0,V1) wie FUN_00406880 (u1/v1 inklusiv -> +1)."""
return (t['u0'] / pw, (t['u1'] + 1) / pw, t['v0'] / ph, (t['v1'] + 1) / ph)
def face_uvs(flags, rect):
"""Pro Face-Ecke (v0,v1,v2) die UV aus dem Texturrechteck, nach FUN_00556510.
Bit 0x20 spiegelt U; Bit 0x800 + Bit 0x10 waehlen die Dreieckshaelfte des Vierecks."""
U0, U1, V0, V1 = rect
m = bool(flags & 0x20)
a, b = (U0, U1) if m else (U1, U0) # a = "rechts" ohne Spiegelung
if not (flags & 0x800):
return [(a, V1), (b, V1), (a, V0)]
if not (flags & 0x10):
return [(a, V1), (b, V1), (b, V0)]
return [(b, V0), (a, V0), (a, V1)]
# ---------------------------------------------------------------- Welt zusammensetzen
def tile_xform(x, y, z, rot):
a = rot / 4096 * 2 * math.pi
ca, sa = math.cos(a), math.sin(a)
def f(vx, vy, vz):
return (x + vx * ca - vz * sa, y + vy, z + vx * sa + vz * ca)
def fn(nx, ny, nz):
return (nx * ca - nz * sa, ny, nx * sa + nz * ca)
return f, fn
def load_level(path):
ch = wad_read(path)
wf = wfpc_flags(ch)
trak = parse_trak(wad_get(ch, 'TRAK'))
vcounts = [len(m.verts) for m in trak]
mp = parse_map_tiles(wad_get(ch, 'MAP'), wf, vcounts)
return ch, wf, trak, mp
Executable
+97
View File
@@ -0,0 +1,97 @@
#!/bin/sh
# Mods für "Ein Königreich für ein Lama" ein- und ausschalten.
#
# lama-mod an Mods aus ~/.wine-lama/mods/*/ aktivieren
# lama-mod aus Originalzustand (L: = unveränderte CD-Daten)
# lama-mod status
# lama-mod debug an Debug-Ansicht (Kollision/unsichtbare Objekte) zuschalten
# lama-mod debug aus Debug-Ansicht abschalten (andere Mods bleiben, wie sie sind)
#
# Eine Mod ist ein Ordner, dessen Dateien so liegen wie auf der CD unter
# Setup/Eng/Cd (z. B. wads/T1L9M001.wad, T191.COR, movies/191INTRO.DAT).
# Aktiviert wird über ein Overlay aus Symlinks: Originaldateien bleiben
# unangetastet, Mod-Dateien kommen dazu bzw. ersetzen gleichnamige.
# Mods mit einer Datei ".aus" im Ordner werden übersprungen (so schaltet
# "lama-mod debug an|aus" die Mod "debug" unabhängig von den anderen).
# Reihenfolge: alphabetisch, spätere Mods gewinnen (testlevel > debug).
set -e
export WINEPREFIX="${WINEPREFIX:-$HOME/.wine-lama}"
MODS="$WINEPREFIX/mods"
OVL="$WINEPREFIX/cd-overlay"
DBG="$MODS/debug"
if [ -d /opt/ein-koenigreich-fuer-ein-lama/cd ]; then
BASE=/opt/ein-koenigreich-fuer-ein-lama/cd
else
BASE="$WINEPREFIX/iso-dk/Setup/Eng/Cd"
fi
overlay_an() {
rm -rf "$OVL"
mkdir -p "$OVL"
(cd "$BASE" && find . -type d) | while IFS= read -r d; do mkdir -p "$OVL/$d"; done
(cd "$BASE" && find . -type f) | while IFS= read -r f; do ln -s "$BASE/$f" "$OVL/$f"; done
for m in "$MODS"/*/; do
[ -d "$m" ] || continue
if [ -e "$m/.aus" ]; then
echo "Mod: $(basename "$m") (abgeschaltet)"
continue
fi
echo "Mod: $(basename "$m")"
(cd "$m" && find . -path ./src -prune -o -name .aus -prune -o \( -type f -o -type l \) -print) | while IFS= read -r f; do
# vorhandene Datei gleichen Namens (Groß-/Kleinschreibung egal) ersetzen
dir=$(dirname "$f"); name=$(basename "$f")
mkdir -p "$OVL/$dir"
find "$OVL/$dir" -maxdepth 1 -iname "$name" -exec rm -f {} +
ln -s "$m/$f" "$OVL/$f"
[ -n "$LEISE" ] || echo " + $f"
done
done
ln -sfn "$OVL" "$WINEPREFIX/dosdevices/l:"
echo "Mods aktiv (L: -> $OVL)"
}
case "${1:-status}" in
an|on)
overlay_an
;;
aus|off)
ln -sfn "$BASE" "$WINEPREFIX/dosdevices/l:"
rm -rf "$OVL"
echo "Originalzustand (L: -> $BASE)"
;;
debug)
if [ ! -d "$DBG/wads" ]; then
echo "Debug-Mod fehlt ($DBG/wads)." >&2; exit 1
fi
case "${2:-status}" in
an|on)
rm -f "$DBG/.aus"
LEISE=1 overlay_an
echo "Debug-Ansicht AN ($(ls "$DBG/wads" | wc -l) Level). Wirkt beim nächsten Laden eines Levels."
;;
aus|off)
touch "$DBG/.aus"
if [ "$(readlink "$WINEPREFIX/dosdevices/l:")" = "$OVL" ]; then
LEISE=1 overlay_an
fi
echo "Debug-Ansicht AUS."
;;
status)
if [ -e "$DBG/.aus" ]; then echo "Debug-Ansicht: aus"; else echo "Debug-Ansicht: an (wenn Mods aktiv)"; fi
;;
*)
sed -n '2,9p' "$0"; exit 1 ;;
esac
;;
status)
echo "L: -> $(readlink "$WINEPREFIX/dosdevices/l:")"
for m in "$MODS"/*/; do
[ -d "$m" ] || continue
if [ -e "$m/.aus" ]; then s=" (abgeschaltet)"; else s=""; fi
echo "installierte Mod: $(basename "$m")$s"
done
;;
*)
sed -n '2,9p' "$0"; exit 1 ;;
esac
+944
View File
@@ -0,0 +1,944 @@
#!/usr/bin/env python3
"""lamatest - unbeaufsichtigter Test-Harness fuer "Ein Koenigreich fuer ein Lama".
Startet groove.exe (Wine) unsichtbar auf Xvfb :5 mit Null-Audio-Sink, laedt den
TestLevel (Welt 9, Kapitel 1) ueber Spielstand-Slot 3 und macht Screenshots.
lamatest.py start Xvfb + Sink + Spiel, wartet aufs Fenster
lamatest.py shot out.png Screenshot (1920x1080) von :5
lamatest.py key <key> [hold] Taste halten (xdotool-Name, Default 0.12 s)
lamatest.py keys <k1> <k2> ... mehrere Tasten nacheinander (k[:hold])
lamatest.py seq <outdir> "TOKENS" Tastenfolge wie run --seq im laufenden Spiel (shot:/fp:/rj: ...)
lamatest.py tolevel Demo -> Hauptmenue -> Slot 3 -> TestLevel 1
lamatest.py tour <outdir> [n] Rundum-Fotos: drehen/laufen/Kamera
lamatest.py status laeuft was? Fenster? Prozesse?
lamatest.py stop Spiel beenden, Sink entladen, Xvfb killen
lamatest.py run <wad> <outdir> [n] WAD als T1L9M001.wad installieren, starten,
tolevel, tour, Crash-Erkennung, Log sichern
lamatest.py restore-wad Original-T1L9M001.wad (.orig) zurueck
run-Optionen: --seq "Up:1.5 x shot:name sleep=1 ..." statt Tour eine Tastenfolge;
shot:NAME = Foto NAME.jpg, fp:NAME = Foto in Lama-Sicht (Return gehalten),
dialog = Dialog wegklicken, keys wie bei 'keys' (Taste[:Haltezeit]),
Up+x:0.8 = Tasten gleichzeitig, rj:T1:T2 = Anlauf-Sprung (Up halten,
nach T1 s X, dann noch T2 s Up),
run:T:x@1.3,shot@5,Left@3-3.4 = Up T s halten, X/Foto/Taste nach Zeitplan
(run/Left:T:... haelt eine andere Taste)
--tour zusaetzlich zur --seq auch die Standard-Tour
--keep getestete WAD installiert lassen (sonst wird die vorher
installierte T1L9M001.wad wiederhergestellt - nicht .orig!)
Zustand/Logs: $LAMATEST_DIR (Default /tmp/lamatest-$USER).
Doku: ../notes/testharness.md
"""
import hashlib
import os
import shutil
import signal
import subprocess
import sys
import time
HOME = os.path.expanduser("~")
DISPLAY = ":5"
SINK = "lama_stumm"
PREFIX = os.path.join(HOME, ".wine-lama")
GAMEDIR = os.path.join(PREFIX, "drive_c", "Program Files", "Disney Interactive",
"Ein Koenigreich fuer ein Lama")
GAMEEXE = r"C:\Program Files\Disney Interactive\Ein Koenigreich fuer ein Lama\groove.exe"
MODWAD = os.path.join(PREFIX, "mods", "testlevel", "wads", "T1L9M001.wad")
OVERLAY = os.path.join(PREFIX, "cd-overlay")
LAMA_MOD = os.path.join(HOME, ".local", "bin", "lama-mod")
STATE = os.environ.get("LAMATEST_DIR", "/tmp/lamatest-%s" % os.environ.get("USER", "x"))
WINELOG = os.path.join(STATE, "wine.log")
XVFBLOG = os.path.join(STATE, "xvfb.log")
SAVEBAK = os.path.join(STATE, "ksv-backup")
ENV = dict(os.environ, DISPLAY=DISPLAY, PULSE_SINK=SINK, WINEPREFIX=PREFIX,
WINEDEBUG=os.environ.get("WINEDEBUG", "fixme-all"))
def log(*a):
print("[lamatest %s]" % time.strftime("%H:%M:%S"), *a, file=sys.stderr, flush=True)
def die(msg, code=1):
log("FEHLER:", msg)
sys.exit(code)
def sh(cmd, check=False, **kw):
kw.setdefault("env", ENV)
return subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
text=True, check=check, **kw)
def pidfile(name):
return os.path.join(STATE, name + ".pid")
def read_pid(name):
try:
pid = int(open(pidfile(name)).read().strip())
os.kill(pid, 0)
return pid
except (OSError, ValueError):
return None
# ---------------------------------------------------------------- Prozesse
def groove_procs():
"""Alle laufenden groove.exe-Prozesse: [(pid, environ-DISPLAY)]."""
out = []
for p in os.listdir("/proc"):
if not p.isdigit():
continue
try:
cmd = open("/proc/%s/cmdline" % p, "rb").read().replace(b"\0", b" ").lower()
except OSError:
continue
if b"groove.exe" not in cmd or b"lamatest" in cmd:
continue
disp = None
try:
for kv in open("/proc/%s/environ" % p, "rb").read().split(b"\0"):
if kv.startswith(b"DISPLAY="):
disp = kv[8:].decode()
except OSError:
pass
out.append((int(p), disp))
return out
def foreign_groove():
return [(p, d) for p, d in groove_procs() if d != DISPLAY]
def xdo(*args):
# Sicherheitsnetz: xdotool immer nur gegen :5
assert ENV["DISPLAY"] == DISPLAY
return sh(["xdotool"] + list(args))
def find_window():
r = xdo("search", "--name", "^Groove$")
ids = r.stdout.split()
return ids[-1] if ids else None
def game_alive():
return any(d == DISPLAY for _, d in groove_procs())
def error_dialog():
"""Wine-Fehlerdialog / Absturzfenster auf :5? Liefert Fenstertitel oder None."""
r = xdo("search", "--name", ".")
titles = []
for wid in r.stdout.split():
t = xdo("getwindowname", wid).stdout.strip()
if t:
titles.append(t)
for t in titles:
tl = t.lower()
if ("program error" in tl or "programmfehler" in tl or "wine" in tl and "error" in tl
or "fehler" in tl or "croc" in tl or "exception" in tl or "winedbg" in tl):
return t
return None
# ---------------------------------------------------------------- Spielstaende
def ksv_files():
return sorted(f for f in os.listdir(GAMEDIR) if f.lower().endswith(".ksv"))
def backup_saves():
os.makedirs(SAVEBAK, exist_ok=True)
for f in ksv_files():
shutil.copy2(os.path.join(GAMEDIR, f), os.path.join(SAVEBAK, f))
def restore_saves():
if not os.path.isdir(SAVEBAK):
return
for f in os.listdir(SAVEBAK):
src, dst = os.path.join(SAVEBAK, f), os.path.join(GAMEDIR, f)
if not os.path.exists(dst) or md5(src) != md5(dst):
log("Spielstand %s wurde veraendert -> wiederhergestellt" % f)
shutil.copy2(src, dst)
for f in ksv_files():
if not os.path.exists(os.path.join(SAVEBAK, f)):
log("neuer Spielstand %s vom Test -> entfernt" % f)
os.remove(os.path.join(GAMEDIR, f))
def md5(p):
return hashlib.md5(open(p, "rb").read()).hexdigest()
def check_slot3():
p = os.path.join(GAMEDIR, "(null)king2.ksv")
if not os.path.exists(p):
die("Slot 3 (%s) fehlt" % p)
b = open(p, "rb").read()
# Kartencursor (+4/+5) bestimmt, was auf der Auswahlkarte vorgewaehlt ist
if len(b) != 256 or b[4] != 9 or b[5] != 1:
log("WARNUNG: Slot 3: Kartencursor nicht auf Welt 9/Kapitel 1 (Cursor %d/%d, "
"aktuell %d/%d)" % (b[4], b[5], b[0x3f], b[0x40]))
# ---------------------------------------------------------------- Kommandos
def cmd_start(timeout=60):
os.makedirs(STATE, exist_ok=True)
f = foreign_groove()
if f:
die("Elias' eigene Spielinstanz laeuft (pid %s) - Test abgebrochen" % f)
if game_alive():
log("Spiel laeuft schon auf %s" % DISPLAY)
return
if not os.path.isdir(OVERLAY) or not os.path.realpath(
os.path.join(PREFIX, "dosdevices", "l:")) == os.path.realpath(OVERLAY):
die("Mod ist nicht aktiv (lama-mod an)")
check_slot3()
backup_saves()
# Xvfb
if not read_pid("xvfb"):
if os.path.exists("/tmp/.X11-unix/X5") or os.path.exists("/tmp/.X5-lock"):
if sh(["xdpyinfo"]).returncode == 0:
log("fremder X-Server auf :5 laeuft bereits - wird mitbenutzt")
else:
die("/tmp/.X5-lock existiert, aber :5 antwortet nicht")
else:
xl = open(XVFBLOG, "w")
p = subprocess.Popen(["Xvfb", DISPLAY, "-screen", "0", "1920x1200x24",
"-nolisten", "tcp", "+extension", "GLX"],
stdout=xl, stderr=xl, start_new_session=True)
open(pidfile("xvfb"), "w").write(str(p.pid))
for _ in range(50):
if sh(["xdpyinfo"]).returncode == 0:
break
time.sleep(0.1)
else:
die("Xvfb startet nicht (%s)" % XVFBLOG)
# Null-Sink (nie die App stummschalten!)
if not os.path.exists(pidfile("sink")):
r = sh(["pactl", "load-module", "module-null-sink", "sink_name=" + SINK,
"sink_properties=device.description=lama_stumm"])
if r.returncode == 0:
open(pidfile("sink"), "w").write(r.stdout.strip())
else:
log("WARNUNG: Null-Sink nicht ladbar:", r.stderr.strip())
# Spiel
wl = open(WINELOG, "w")
p = subprocess.Popen(["wine", GAMEEXE], cwd=GAMEDIR, env=ENV, stdout=wl,
stderr=subprocess.STDOUT, start_new_session=True)
open(pidfile("wine"), "w").write(str(p.pid))
t0 = time.time()
while time.time() - t0 < timeout:
if find_window():
log("Fenster da nach %.1f s" % (time.time() - t0))
return
if p.poll() is not None and not game_alive():
die("wine beendet sich sofort (Log: %s)" % WINELOG)
time.sleep(0.5)
die("kein Groove-Fenster nach %d s" % timeout)
def shot(path):
d = os.path.dirname(os.path.abspath(path))
os.makedirs(d, exist_ok=True)
r = sh(["import", "-display", DISPLAY, "-window", "root", "-crop",
"1920x1080+0+0", "+repage", path])
if r.returncode:
die("import: " + r.stderr)
return path
def small_shot(path):
"""Kleiner Screenshot (960x540 JPEG) fuer Serien - Platte ist knapp.
Mit LAMATEST_SHOT=1280 (Umgebung) 1280x720 / Qualitaet 85 (Vorschaubilder)."""
w = os.environ.get("LAMATEST_SHOT")
scale, q = ("%sx" % w, "85") if w else ("50%", "80")
r = sh(["import", "-display", DISPLAY, "-window", "root", "-crop",
"1920x1080+0+0", "+repage", "-resize", scale, "-quality", q, path])
if r.returncode:
die("import: " + r.stderr)
return path
def key(k, hold=0.12):
wid = find_window()
if not wid:
die("kein Groove-Fenster")
xdo("windowfocus", "--sync", wid)
xdo("keydown", k)
time.sleep(float(hold))
xdo("keyup", k)
def keys(seq, gap=0.35):
for item in seq:
if item.startswith("sleep="):
time.sleep(float(item[6:]))
continue
k, _, h = item.partition(":")
key(k, float(h) if h else 0.12)
time.sleep(gap)
# Bildvergleich ueber kleine Graustufen-Thumbnails (nur ImageMagick noetig)
def thumb(path=None):
src = ["import", "-display", DISPLAY, "-window", "root", "-crop", "1920x1080+0+0",
"+repage"] if path is None else ["convert", path]
r = subprocess.run(src + ["-resize", "48x27!", "-colorspace", "Gray", "-depth", "8",
"gray:-"], stdout=subprocess.PIPE, env=ENV)
return r.stdout
def diff(a, b):
if not a or not b or len(a) != len(b):
return 255.0
return sum(abs(x - y) for x, y in zip(a, b)) / len(a)
def mean(t):
return sum(t) / len(t) if t else 0
def wait_stable(min_time=0.0, timeout=30, still=1.5, thresh=1.0, dark_ok=False):
"""Warten bis sich das Bild `still` Sekunden kaum aendert."""
t0 = time.time()
time.sleep(min_time)
last, since = thumb(), time.time()
while time.time() - t0 < timeout:
time.sleep(0.4)
cur = thumb()
if diff(cur, last) > thresh or (not dark_ok and mean(cur) < 3):
since = time.time()
elif time.time() - since >= still:
return cur
last = cur
return last
# ---------------------------------------------------------------- Bilderkennung
def rgb_thumb():
"""48x27 RGB-Thumbnail des aktuellen Bildes (Liste von (r,g,b)-Zeilen)."""
r = subprocess.run(["import", "-display", DISPLAY, "-window", "root", "-crop",
"1920x1080+0+0", "+repage", "-resize", "48x27!", "-depth", "8",
"rgb:-"], stdout=subprocess.PIPE, env=ENV)
b = r.stdout
if len(b) != 48 * 27 * 3:
return None
return [[tuple(b[(y * 48 + x) * 3:(y * 48 + x) * 3 + 3]) for x in range(48)]
for y in range(27)]
def _rows_mean(t, ys):
px = [sum(c) / 3 for y in ys for c in t[y]]
return sum(px) / len(px)
def classify(t=None):
"""Grobe Bildklasse: black / letterbox / map / bright (Menue oder Spiel)."""
t = t or rgb_thumb()
if t is None:
return "none"
allm = _rows_mean(t, range(27))
if allm < 6:
return "black"
# nur oberste/unterste Thumbnail-Zeile pruefen: Untertitel liegen im unteren Balken
top, bot, mid = _rows_mean(t, (0,)), _rows_mean(t, (26,)), _rows_mean(t, range(6, 18))
if top < 4 and bot < 4 and mid > 15:
return "letterbox" # Kamera-Flug / Dialog-Zwischensequenz
# Auswahlkarte Welt 9: fast einfarbig beige (~205,175,110)
beige = sum(1 for row in t for (r, g, b) in row
if 180 < r < 230 and 150 < g < 200 and 85 < b < 140 and r > g > b)
if beige > 0.75 * 48 * 27:
return "map"
return "bright"
def hud_visible():
"""Goldene Energieleiste/Sonnenkrone oben links (Gameplay-HUD)?"""
r = subprocess.run(["import", "-display", DISPLAY, "-window", "root", "-crop",
"900x110+100+10", "+repage", "-resize", "25%", "-depth", "8",
"rgb:-"], stdout=subprocess.PIPE, env=ENV)
b = r.stdout
gold = sum(1 for i in range(0, len(b) - 2, 3)
if b[i] > 200 and b[i + 1] > 150 and b[i + 2] < 90)
return gold > 150
def ocr(crop="1920x1080+0+0", threshold=None, psm=11, scale="50%"):
"""Text auf dem Bildschirm (tesseract, deu), klein geschrieben.
threshold (Prozent): nur sehr helle Schrift behalten (z. B. markierter Menuepunkt)."""
filt = ["-threshold", "%d%%" % threshold] if threshold else []
raw = subprocess.run(["import", "-display", DISPLAY, "-window", "root", "-crop",
crop, "+repage", "ppm:-"], stdout=subprocess.PIPE, env=ENV).stdout
img = subprocess.run(["convert", "ppm:-", "-colorspace", "Gray"] + filt +
["-negate", "-resize", scale, "pgm:-"], input=raw,
stdout=subprocess.PIPE).stdout
r = subprocess.run(["tesseract", "-", "-", "-l", "deu", "--psm", str(psm)], input=img,
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
return " ".join(r.stdout.decode("utf-8", "replace").lower().split())
def crashed():
"""None oder Grund-String, wenn das Spiel abgestuerzt ist."""
if not game_alive():
return "groove.exe-Prozess weg"
e = error_dialog()
if e:
return "Fehlerdialog: %s" % e
if not find_window():
return "Groove-Fenster weg"
return None
def wait_for(pred, timeout, what, poll=0.7):
t0 = time.time()
while time.time() - t0 < timeout:
c = crashed()
if c:
raise Crash(c)
v = pred()
if v:
return v
time.sleep(poll)
raise Timeout("Zeitueberschreitung (%ds) beim Warten auf %s" % (timeout, what))
class Crash(Exception):
pass
class Timeout(Exception):
pass
SLOT_CROP = "1600x280+160+740" # Textfeld "Gespeichertes Spiel N / Level Kapitel"
def slot_text():
# zwei Schwellen kombinieren - der Hintergrund flimmert, OCR schwankt
return ocr(SLOT_CROP, threshold=70, psm=6) + " | " + ocr(SLOT_CROP, threshold=82, psm=6)
def fuzzy_in(word, text, ratio=0.75):
"""word (evtl. mehrere Woerter) unscharf in text enthalten?"""
import difflib
if word in text:
return True
toks = text.split()
n = len(word.split())
for i in range(len(toks) - n + 1):
cand = " ".join(toks[i:i + n])
if difflib.SequenceMatcher(None, word, cand).ratio() >= ratio:
return True
return False
def _has(*words):
def f():
t = slot_text()
return t if all(fuzzy_in(w, t) for w in words) else None
return f
def tolevel(skip_flyby=True, log_times=None):
"""Hauptmenue -> Spiel starten -> Slot mit TestLevel -> Kapitel 1 -> Gameplay."""
T = log_times if log_times is not None else {}
t0 = time.time()
# 1) Hauptmenue (Logo ~2 s, Menue nach ~5-6 s). Kommt stattdessen die Demo,
# bringt Return zurueck ins Menue.
tx = time.time()
while True:
c = crashed()
if c:
raise Crash(c)
# Hauptmenue: Menuepunkte rechts unten; bei 75 % Schwelle bleibt nur der
# markierte (helle) Punkt lesbar -> prueft zugleich die Cursorposition.
txt = ocr("700x300+1150+650", threshold=75, psm=6, scale="100%")
if fuzzy_in("spiel starten", txt):
log("Hauptmenue erkannt")
time.sleep(0.5)
key("Return")
break
if fuzzy_in("gespeichertes", slot_text()):
break # schon im Slot-Bildschirm
if time.time() - tx > 40:
log("kein Menue erkannt -> Return (Demo/Logo ueberspringen)")
key("Return")
tx = time.time()
if time.time() - t0 > 90:
raise Timeout("Hauptmenue nicht erreicht")
time.sleep(1)
T["menu"] = round(time.time() - t0, 1)
# 2) Spielstandauswahl: ganz nach links (kein Wrap), dann den Slot mit TestLevel suchen
wait_for(_has("gespeichertes"), 20, "Spielstandauswahl")
time.sleep(0.4)
# Dreier-Slider ohne Wrap: Slot 3 ist ganz rechts, weiteres Right bleibt dort.
# Also einfach 2x Right (egal wo man startet) und einmal per OCR pruefen.
def _is_test():
return (fuzzy_in("testlevel", ocr(SLOT_CROP, threshold=70, psm=6))
or fuzzy_in("testlevel", ocr(SLOT_CROP, threshold=82, psm=6)))
for _ in range(2):
key("Right", 0.3) # 0.3 s halten: bei niedriger Bildrate gehen kurze Druecke verloren
time.sleep(0.3)
for i in range(3): # unter Last kann ein Druck verloren gehen -> noch einmal Right
time.sleep(0.6)
if _is_test():
log("Slot 3: TestLevel gefunden (Versuch %d)" % (i + 1))
break
key("Right", 0.3)
else:
raise Timeout("kein Spielstand mit TestLevel gefunden (Slot 3 kaputt?)")
key("Return")
T["slot"] = round(time.time() - t0, 1)
# 3) Auswahlkarte
wait_for(lambda: classify() == "map", 30, "Auswahlkarte")
time.sleep(1.0)
# Vorauswahl auf der Karte = Kartencursor im KSV (+4/+5), geprueft in check_slot3()
key("Return")
t_load = time.time()
T["map"] = round(t_load - t0, 1)
# 4) Laden -> (Kamera-Flug im Letterbox) -> Gameplay
skipped = False
good = 0
states = []
while time.time() - t_load < 120:
c = crashed()
if c:
raise Crash(c)
st = classify()
if not states or states[-1] != st:
states.append(st)
log("Zustand: %s (%.1f s nach Level-Start)" % (st, time.time() - t_load))
if st == "letterbox" and skip_flyby and not skipped:
time.sleep(0.5)
key("Return") # Kamera-Flug ueberspringen
skipped = True
T["flyby"] = round(time.time() - t_load, 1)
elif st == "letterbox" and skipped and time.time() - t_load > 15:
key("x") # Dialog weiterklicken
# Gameplay = helles Bild, kein Letterbox, und HUD sichtbar (oder nach
# Kamera-Flug / spaetestens 25 s - falls das HUD schon ausgeblendet ist)
el = time.time() - t_load
if st == "bright" and el > 2 and (hud_visible() or "letterbox" in states or el > 25):
good += 1
if good >= 3:
break
else:
good = 0
time.sleep(0.6)
else:
raise Timeout("Gameplay nicht erreicht (Zustaende: %s)" % states)
T["gameplay"] = round(time.time() - t0, 1)
T["load"] = round(time.time() - t_load, 1)
T["states"] = states
log("Gameplay nach %.1f s (Laden %.1f s), Zustaende %s" % (T["gameplay"], T["load"], states))
return T
def cmd_tolevel():
try:
T = tolevel()
except (Crash, Timeout) as e:
die(str(e))
print(T)
def settle_dialog(maxpress=12):
"""Dialog-/Zwischensequenz (Letterbox) wegklicken."""
n = 0
while classify() == "letterbox" and n < maxpress:
key("x")
time.sleep(1.0)
n += 1
return n
def tour(outdir, n=8, prefix="tour"):
"""Fotoserie: n Bilder Kamera-Rundumblick (A), dann n Bilder Laufen+Drehen.
Liefert Liste der Dateien. Bilder als 960x540-JPEG (Platz sparen)."""
os.makedirs(outdir, exist_ok=True)
files = []
def snap(tag):
c = crashed()
if c:
raise Crash(c)
f = os.path.join(outdir, "%s_%02d_%s.jpg" % (prefix, len(files), tag))
small_shot(f)
files.append(f)
snap("start")
# Kamera einmal rundum: 0.5 s A-Taste ~ 90 Grad
hold = 2.0 / max(n, 1)
for i in range(n):
key("a", hold)
time.sleep(0.7)
snap("cam%d" % i)
# Laufen: vorwaerts, zwischendurch drehen; Dialoge wegklicken
for i in range(n):
key("Up", 1.2)
time.sleep(0.4)
if classify() == "letterbox":
snap("dialog%d" % i)
settle_dialog()
snap("walk%d" % i)
key("Left" if i % 3 != 2 else "Right", 0.45)
time.sleep(0.3)
# Lama-Sicht (Return gehalten) einmal pro Richtung
key("Return", 1.5)
time.sleep(0.2)
snap("lamasicht")
return files
def cmd_tour(outdir, n):
try:
fs = tour(outdir, n)
except Crash as e:
die("ABSTURZ: %s" % e, 3)
print("\n".join(fs))
# ---------------------------------------------------------------- WAD + run
def install_wad(wad):
wad = os.path.abspath(wad)
orig = MODWAD + ".orig"
if not os.path.exists(orig):
shutil.copy2(MODWAD, orig)
log("Original gesichert: %s" % orig)
if os.path.abspath(MODWAD) != wad:
shutil.copyfile(wad, MODWAD)
# Overlay zeigt per Symlink auf MODWAD -> neu bauen nur wenn Link fehlt/falsch
link = os.path.join(OVERLAY, "wads", "T1L9M001.wad")
if not (os.path.islink(link) and os.path.realpath(link) == os.path.realpath(MODWAD)):
log("Overlay-Link fehlt -> lama-mod an")
sh([LAMA_MOD, "an"], check=True)
others = [f for f in os.listdir(os.path.dirname(link))
if f.lower() == "t1l9m001.wad" and f != "T1L9M001.wad"]
if others:
log("WARNUNG: weitere Schreibweise im Overlay: %s" % others)
log("WAD installiert: %s (%d Bytes, md5 %s)" % (wad, os.path.getsize(MODWAD), md5(MODWAD)[:12]))
def cmd_restore_wad():
orig = MODWAD + ".orig"
if not os.path.exists(orig):
die("kein %s" % orig)
shutil.copy2(orig, MODWAD)
log("Original-T1L9M001.wad wiederhergestellt")
def run_seq(outdir, seq):
"""Tastenfolge abarbeiten; 'shot:NAME' fotografiert. Liefert Bilddateien."""
files = []
for item in seq.split():
c = crashed()
if c:
raise Crash(c)
if item.startswith("shot:"):
f = os.path.join(outdir, "seq_%02d_%s.jpg" % (len(files), item[5:]))
small_shot(f)
files.append(f)
elif item.startswith("fp:"):
# Lama-Sicht: Return halten, waehrenddessen fotografieren
wid = find_window()
xdo("windowfocus", "--sync", wid)
xdo("keydown", "Return")
time.sleep(1.2)
f = os.path.join(outdir, "seq_%02d_%s.jpg" % (len(files), item[3:]))
small_shot(f)
files.append(f)
xdo("keyup", "Return")
time.sleep(0.4)
elif item.startswith("rj:"):
# Anlauf-Sprung: Up halten, nach T1 s X tippen, Up noch T2 s halten
_, t1, t2 = item.split(":")
wid = find_window()
xdo("windowfocus", "--sync", wid)
xdo("keydown", "Up")
time.sleep(float(t1))
xdo("keydown", "x")
time.sleep(0.15)
xdo("keyup", "x")
time.sleep(float(t2))
xdo("keyup", "Up")
time.sleep(0.3)
elif item.startswith("run:") or item.startswith("run/"):
# Durchlaufen mit Zeitplan: run:T:x@2.1,x@3.4,shot@4.0,Left@5.0-5.3
# Up (bzw. bei run/Left:... die angegebene Taste) wird T s gehalten; zu den Zeiten (s ab Beginn) X tippen / Foto (seq_NN_runK) /
# Taste von-bis halten. Fotos dauern ~0.2 s, der Zeitplan laeuft weiter (Echtzeit).
parts = item.split(":", 2)
mk = parts[0][4:] or "Up"
total, ev = float(parts[1]), []
for e in (parts[2].split(",") if len(parts) > 2 and parts[2] else []):
k, _, t = e.partition("@")
if "-" in t:
a, b = t.split("-")
ev.append((float(a), "down", k)); ev.append((float(b), "up", k))
else:
ev.append((float(t), k, None))
ev.sort(key=lambda e: e[0])
wid = find_window()
xdo("windowfocus", "--sync", wid)
t0 = time.time()
xdo("keydown", mk)
for t, k, arg in ev + [(total, "end", None)]:
dt = t0 + t - time.time()
if dt > 0:
time.sleep(dt)
if k == "end":
break
if k == "down":
xdo("keydown", arg)
elif k == "up":
xdo("keyup", arg)
elif k == "shot":
f = os.path.join(outdir, "seq_%02d_run%.1f.jpg" % (len(files), t))
small_shot(f)
files.append(f)
else:
xdo("keydown", k); time.sleep(0.1); xdo("keyup", k)
xdo("keyup", mk)
for t, k, arg in ev:
if k == "down":
xdo("keyup", arg)
time.sleep(0.3)
elif item.startswith("hop:"):
# Up halten und N-mal alle T s springen (Fotos dazwischen: shot nach dem Token)
_, n, t = item.split(":")
wid = find_window()
xdo("windowfocus", "--sync", wid)
xdo("keydown", "Up")
for _ in range(int(n)):
xdo("keydown", "x"); time.sleep(0.12); xdo("keyup", "x"); time.sleep(float(t) - 0.12)
xdo("keyup", "Up")
time.sleep(0.3)
elif item.startswith("dj:"):
# Doppelsprung mit Anlauf: Up halten, X, nach 0.3 s nochmal X, Up noch T s
t = float(item.split(":")[1])
wid = find_window()
xdo("windowfocus", "--sync", wid)
xdo("keydown", "Up")
for _ in range(2):
xdo("keydown", "x"); time.sleep(0.12); xdo("keyup", "x"); time.sleep(0.25)
time.sleep(t)
xdo("keyup", "Up")
time.sleep(0.3)
elif "+" in item.split(":")[0]:
# mehrere Tasten gleichzeitig: Up+x:0.8
ks, _, h = item.partition(":")
wid = find_window()
xdo("windowfocus", "--sync", wid)
for k in ks.split("+"):
xdo("keydown", k)
time.sleep(float(h) if h else 0.12)
for k in reversed(ks.split("+")):
xdo("keyup", k)
time.sleep(0.3)
elif item == "dialog":
settle_dialog()
else:
keys([item])
return files
def cmd_run(wad, outdir, n=8, seq=None, do_tour=None, keep=False):
os.makedirs(outdir, exist_ok=True)
report = os.path.join(outdir, "report.txt")
rep = ["wad: %s" % os.path.abspath(wad)]
status = 0
if foreign_groove():
die("Elias' eigene Spielinstanz laeuft - abgebrochen")
# vorher installierte WAD merken -> danach zurueck (nicht .orig!)
prev = os.path.join(STATE, "vorher_T1L9M001.wad")
os.makedirs(STATE, exist_ok=True)
if not keep and os.path.exists(MODWAD):
shutil.copyfile(MODWAD, prev)
install_wad(wad)
rep.append("md5: %s" % md5(MODWAD))
t0 = time.time()
try:
cmd_start()
rep.append("fenster: %.1f s" % (time.time() - t0))
T = tolevel()
rep.append("tolevel: %s" % T)
small_shot(os.path.join(outdir, "00_geladen.jpg"))
shot(os.path.join(outdir, "00_geladen.png"))
fs = []
if seq:
fs += run_seq(outdir, seq)
if do_tour or (do_tour is None and not seq):
fs += tour(outdir, n)
rep.append("bilder: %d" % (len(fs) + 2))
# eingefroren? (Kamera dreht sich -> aufeinanderfolgende Bilder muessen sich aendern)
th = [thumb(f) for f in fs]
dmax = max([diff(a, b) for a, b in zip(th, th[1:])] or [0])
rep.append("bildaenderung max: %.1f" % dmax)
if dmax < 1.0:
status = 5
rep.append("WARNUNG: Bild scheint eingefroren")
c = crashed()
if c:
raise Crash(c)
if status == 0:
rep.append("ergebnis: OK (laeuft nach %.0f s noch)" % (time.time() - t0))
else:
rep.append("ergebnis: EINGEFROREN?")
except Crash as e:
status = 3
rep.append("ergebnis: ABSTURZ - %s" % e)
try:
small_shot(os.path.join(outdir, "zz_absturz.jpg"))
except SystemExit:
pass
except Timeout as e:
status = 4
rep.append("ergebnis: HAENGT/TIMEOUT - %s" % e)
try:
small_shot(os.path.join(outdir, "zz_timeout.jpg"))
except SystemExit:
pass
finally:
try:
cmd_stop()
finally:
if os.path.exists(WINELOG):
# Log kuerzen: max. 2 MB behalten (Anfang + Ende)
data = open(WINELOG, "rb").read()
if len(data) > 2_000_000:
data = data[:1_000_000] + b"\n...[gekuerzt]...\n" + data[-1_000_000:]
open(os.path.join(outdir, "wine.log"), "wb").write(data)
# bekannte harmlose Meldungen (laufen auch bei funktionierendem Spiel)
noise = ("com_get_class_object", "ChangeDisplaySettingsExW",
"wined3d_restore_display_modes", "gstreamer", "MIDIMAP",
"RestoreDisplayMode")
errs = [l for l in data.decode("latin-1").splitlines()
if (":err:" in l or "exception" in l.lower() or "Unhandled" in l
or "croc" in l.lower()) and not any(x in l for x in noise)]
rep.append("wine-log: %d err/exception-Zeilen" % len(errs))
rep += [" " + l for l in errs[:15]]
if not keep and os.path.exists(prev):
shutil.copyfile(prev, MODWAD)
os.remove(prev)
rep.append("WAD: vorher installierte Version wiederhergestellt (md5 %s)" % md5(MODWAD)[:12])
elif keep:
rep.append("WAD: getestete Version bleibt installiert")
rep.append("dauer: %.0f s" % (time.time() - t0))
open(report, "w").write("\n".join(rep) + "\n")
print("\n".join(rep))
return status
def cmd_stop():
f = foreign_groove()
mine = [p for p, d in groove_procs() if d == DISPLAY]
for p in mine:
try:
os.kill(p, signal.SIGTERM)
except OSError:
pass
time.sleep(1)
if f:
log("fremde Instanz laeuft (%s) - kein wineserver -k, nur eigene Prozesse" % f)
for p, d in groove_procs():
if d == DISPLAY:
os.kill(p, signal.SIGKILL)
else:
sh(["wineserver", "-k"])
sh(["wineserver", "-w"], timeout=20)
wp = read_pid("wine")
if wp:
try:
os.killpg(wp, signal.SIGKILL)
except OSError:
pass
xp = read_pid("xvfb")
if xp:
os.kill(xp, signal.SIGTERM)
for _ in range(30):
if not read_pid("xvfb"):
break
time.sleep(0.1)
try:
os.remove(pidfile("xvfb"))
except OSError:
pass
try:
mod = open(pidfile("sink")).read().strip()
if mod:
sh(["pactl", "unload-module", mod])
os.remove(pidfile("sink"))
except OSError:
pass
# Sicherheitsnetz: Sink per Name entladen, falls Modulnummer verloren
r = sh(["pactl", "list", "short", "modules"])
for line in r.stdout.splitlines():
if "sink_name=" + SINK in line:
sh(["pactl", "unload-module", line.split()[0]])
restore_saves()
log("gestoppt")
def cmd_status():
print("eigene groove.exe (:5):", [p for p, d in groove_procs() if d == DISPLAY])
print("fremde groove.exe: ", foreign_groove())
print("Xvfb-pid:", read_pid("xvfb"), " Fenster:", find_window() if read_pid("xvfb") else None)
print("Fehlerdialog:", error_dialog() if read_pid("xvfb") else None)
print("Mod-WAD:", MODWAD, md5(MODWAD) if os.path.exists(MODWAD) else "-")
def main(argv):
if len(argv) < 2 or argv[1] in ("-h", "--help", "help"):
print(__doc__)
return
c, a = argv[1], argv[2:]
if c == "start":
cmd_start()
elif c == "stop":
cmd_stop()
elif c == "status":
cmd_status()
elif c == "shot":
print(shot(a[0] if a else "shot.png"))
elif c == "key":
key(a[0], float(a[1]) if len(a) > 1 else 0.12)
elif c == "keys":
keys(a)
elif c == "tolevel":
cmd_tolevel()
elif c == "seq": # Tastenfolge wie run --seq, aber im laufenden Spiel: seq <outdir> "TOKENS"
os.makedirs(a[0], exist_ok=True)
for f in run_seq(a[0], a[1]):
print(f)
elif c == "tour":
cmd_tour(a[0], int(a[1]) if len(a) > 1 else 8)
elif c == "run":
seq, do_tour, keep, pos = None, None, False, []
i = 0
while i < len(a):
if a[i] == "--seq":
seq = a[i + 1]; i += 2; continue
if a[i] == "--tour":
do_tour = True
elif a[i] == "--keep":
keep = True
else:
pos.append(a[i])
i += 1
sys.exit(cmd_run(pos[0], pos[1], int(pos[2]) if len(pos) > 2 else 8, seq, do_tour, keep))
elif c == "restore-wad":
cmd_restore_wad()
elif c == "mean": # Debug: Helligkeit/Diff
print(mean(thumb(a[0] if a else None)))
else:
print(__doc__)
sys.exit(2)
if __name__ == "__main__":
main(sys.argv)
+993
View File
@@ -0,0 +1,993 @@
#!/usr/bin/env python3
"""levelbuild - eigene Level fuer "Ein Koenigreich fuer ein Lama" bauen (Croc-2-Engine, PC).
Ein Level wird als Python-Skript beschrieben, das eine Funktion ``build(L)`` enthaelt.
``L`` ist ein :class:`Level` mit Grundkoerpern (Boxen, Plattformen, Rampen, Treppen, ...),
Texturen per Name, automatischer Vertexfarben-Beleuchtung (Sonne, Himmel, Schatten,
Umgebungsverdeckung) und Objekt-Platzierung (Strats per Typname, Parameter vom Original).
levelbuild.py LEVEL.py OUT.wad [--base t1l1m001.wad] [--install] [--info]
levelbuild.py --types [--base ...] platzierbare Strat-Typen des Basislevels
levelbuild.py --textures OUT.png Kontaktabzug der Texturen (mit Namen/Index)
Koordinaten (wie im Spiel / bei Strats): x nach Osten, y nach oben, z = Rasterzeile
(positiv). 1 Einheit = 1 Rasterzelle. Das Raster des Basislevels ist 96x96 (x,z in 0..96).
Winkel in Grad (0..360) um die y-Achse.
Doku: ../notes/levelbau.md
"""
import argparse
import hashlib
import importlib.util
import math
import os
import random
import struct
import sys
import numpy as np
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
import geomlib as G # noqa: E402
import mapchunk as M # noqa: E402
import stpcwalk # noqa: E402
import strats as S # noqa: E402
WADS = os.path.expanduser('~/.wine-lama/iso-dk/Setup/Eng/Cd/wads')
BASE = os.path.join(WADS, 't1l1m001.wad')
MODWAD = os.path.expanduser('~/.wine-lama/mods/testlevel/wads/T1L9M001.wad')
FX = 4096
# Texturnamen fuer das Dorf (t1l1m001). Kontaktabzug: notes/img/texturen_dorf.png
TEX = {
'gras': 1, 'gras2': 170, 'gras_hell': 190, 'gras_streifen': 212, 'gras_dunkel': 157,
'gras_fleck': 99, 'blumen': 87, 'blumen2': 81, 'blumen3': 82,
'sand': 92, 'sand2': 14, 'sand3': 11, 'erde': 3, 'erde2': 4, 'erde3': 5,
'graskante': 38, 'graskante2': 2, 'graskante_sand': 12,
'stein': 149, 'stein2': 166, 'stein_blau': 154, 'stein_kante': 145,
'stein_gruen': 169, 'stein_gruen2': 148, 'stein_moos': 199,
'platte': 214, 'platte2': 225, 'platte_kante': 223,
'holz_dunkel': 203, 'bambus': 141, 'bambus2': 201, 'holz_maser': 127, 'holz_maser2': 128,
'kiste': 233, 'rinde': 222, 'holz_braun': 126, 'holz_braun2': 129, 'holzbalken': 142,
'holz_ringe': 119, 'holzkante': 118,
'inka': 131, 'muster_lila': 202, 'muster_lila2': 208, 'muster_lila3': 200,
'pilz_rot': 136, 'pilz_rot2': 137, 'treppe_stein': 63,
}
# zusaetzliche Flachfarben (werden an die Texturtabelle angehaengt): Name -> (R, G, B)
FLAT = {
'meer': (40, 120, 150), 'meer_tief': (25, 85, 120), 'weiss': (235, 235, 225),
'rot': (190, 50, 40), 'gelb': (230, 190, 60), 'blau': (60, 90, 190), 'schwarz': (20, 20, 20),
'gruen': (70, 140, 50), 'grau': (120, 120, 120),
}
# Strat-Typen, die im Dorf-Code stehen, aber im Dorf nicht platziert sind:
# Typfelder (nvars, Slots, Flags, Zusatz) + Parameter aus einem anderen Level uebernehmen
FOREIGN = {'RubyIdol': 't1l1m002.wad'}
def deg(a):
return int(round((a % 360) / 360 * 4096)) & 0xfff
# ---------------------------------------------------------------- Geometrie-Sammelbehaelter
class Quad:
__slots__ = ('p', 'tex', 'coll', 'mat', 'tint', 'shade', 'double', 'render')
def __init__(self, p, tex, coll=True, mat=0, tint=(1, 1, 1), shade=True, double=False, render=True):
self.p = p # 3 oder 4 Punkte (Spielkoordinaten)
self.tex, self.coll, self.mat, self.tint, self.shade, self.double = tex, coll, mat, tint, shade, double
self.render = render # False: nur Kollision (unsichtbar)
def _sub(a, b):
return (a[0] - b[0], a[1] - b[1], a[2] - b[2])
def _cross(a, b):
return (a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0])
def _norm(v):
ln = math.sqrt(sum(c * c for c in v)) or 1.0
return tuple(c / ln for c in v)
def to_exp(p):
"""Spielraum (z positiv) -> Exportraum/Vertexraum (z negativ)."""
return (p[0], p[1], -p[2])
class Level:
"""Baukasten. Alle Methoden nehmen Spielkoordinaten (x, z = Rasterzelle, y oben)."""
def __init__(self, base=BASE, name='Testwelt', seed=1):
self.base = base
self.name = name
self.rng = random.Random(seed)
self.quads = []
self.solids = [] # AABBs (x0,y0,z0,x1,y1,z1) fuer Schatten/Umgebungsverdeckung
self.strats = [] # dict(name, x,y,z, rot, params, wp, variant)
self.kill = None # (y, x0, z0, x1, z1)
self.sun = dict(azimuth=215, elevation=50, color=(1.0, 0.93, 0.80), strength=0.62,
sky=(0.50, 0.55, 0.66), ground=(0.30, 0.28, 0.24), ao=0.55, ao_radius=1.3,
shadow=0.35)
self.flats_used = {}
self._load()
# ------------------------------------------------------------ Basislevel
def _load(self):
self.ch = G.wad_read(self.base)
self.wf = G.wfpc_flags(self.ch)
self.trak0 = G.parse_trak(G.wad_get(self.ch, 'TRAK'))
self.pages, self.texs = G.parse_text(G.wad_get(self.ch, 'TEXT'))
vc = [len(m.verts) for m in self.trak0]
self.map = M.parse(G.wad_get(self.ch, 'MAP '), self.wf, vc)
self.W, self.H = self.map['W'], self.map['H']
st = G.wad_get(self.ch, 'STPC')
self.stpc = st
self.code_lo = stpcwalk.walk(st, self.wf)['code']
self.protos = {} # name -> [instanz-tupel je Code-Variante]
for s in self.map['strats']:
nm = (S.strat_name(st, s[6], self.code_lo) or '?@%x' % s[6]).replace('(spawn)', '')
lst = self.protos.setdefault(nm, [])
if all(p[6] != s[6] for p in lst):
lst.append(s)
self.pool0 = list(self.map['list1'])
self.foreign_params = {}
named = dict((n, o) for o, n in S.all_named(st, self.code_lo))
for nm, wad in FOREIGN.items():
if nm in self.protos or nm not in named:
continue
try:
ch2, wf2, m2, lo2, types2 = S.level_types(os.path.join(WADS, wad))
except Exception:
continue
if nm in types2:
s = list(types2[nm][0])
prm = m2['list1'][s[8]:s[8] + s[7]] if s[7] else []
s[6] = named[nm]
s[13] = -1
self.protos[nm] = [tuple(s)]
self.foreign_params[nm] = prm
def types(self):
return sorted(self.protos)
def tex(self, t):
"""Texturname, Index (int) oder Flachfarbe ('meer' / (r,g,b))."""
if isinstance(t, int):
return t
if isinstance(t, tuple):
key = 'rgb%02x%02x%02x' % t
FLAT.setdefault(key, t)
t = key
if t in TEX:
return TEX[t]
if t in FLAT:
if t not in self.flats_used:
self.flats_used[t] = len(self.texs) + len(self.flats_used)
return self.flats_used[t]
raise KeyError('unbekannte Textur %r (Namen: %s)' % (t, ', '.join(sorted(TEX) + sorted(FLAT))))
# ------------------------------------------------------------ Grundbausteine
def quad(self, p, tex, coll=True, mat=0, tint=(1, 1, 1), rot=0, shade=True, double=False, render=True):
"""Ein Viereck p = [unten-links, unten-rechts, oben-rechts, oben-links] von vorne gesehen
(Textur aufrecht). rot = 0..3 dreht die Textur in 90-Grad-Schritten."""
p = [tuple(map(float, q)) for q in p]
rot %= 4
p = p[rot:] + p[:rot]
self.quads.append(Quad(p, self.tex(tex), coll, mat, tint, shade, double, render))
def collider(self, p, mat=0, normal=(0, 1, 0)):
"""Unsichtbare Kollisionsflaeche (3/4 Punkte), Vorderseite Richtung 'normal'."""
self.quad(_orient([tuple(map(float, q)) for q in p], normal), 'gras', coll=True, mat=mat, render=False)
def _hface(self, x0, z0, x1, z1, y, tex, up=True, step=1.0, rot='random', tint=(1, 1, 1), coll=True, mat=0):
"""Waagerechte Flaeche, in Kacheln (step) unterteilt, an ganzen Zahlen ausgerichtet."""
xs = _cuts(x0, x1, step)
zs = _cuts(z0, z1, step)
for xa, xb in zip(xs, xs[1:]):
for za, zb in zip(zs, zs[1:]):
r = self.rng.choice((0, 2)) if rot == 'random' else rot # 2 Varianten: weniger Modelle
if up: # von oben gesehen: unten-links = (xa, zb)? -> Normale +y
p = [(xa, y, za), (xb, y, za), (xb, y, zb), (xa, y, zb)]
else:
p = [(xa, y, zb), (xb, y, zb), (xb, y, za), (xa, y, za)]
self.quad(_orient(p, (0, 1 if up else -1, 0)), tex, coll=coll, mat=mat, tint=tint, rot=r)
def _vface(self, a, b, y0, y1, tex, top_tex=None, step=1.0, tint=(1, 1, 1), coll=True, mat=0, normal=None):
"""Senkrechte Wand von a=(x,z) nach b=(x,z), aussen = rechts von a->b (von oben, z nach unten)."""
L = math.hypot(b[0] - a[0], b[1] - a[1])
if L < 1e-6 or y1 - y0 < 1e-6:
return
n = max(1, int(round(L / step)))
ys = _cuts(y0, y1, step, align=False)
for i in range(n):
t0, t1 = i / n, (i + 1) / n
pa = (a[0] + (b[0] - a[0]) * t0, a[1] + (b[1] - a[1]) * t0)
pb = (a[0] + (b[0] - a[0]) * t1, a[1] + (b[1] - a[1]) * t1)
for k, (ya, yb) in enumerate(zip(ys, ys[1:])):
tx = top_tex if (top_tex is not None and k == len(ys) - 2) else tex
p = [(pa[0], ya, pa[1]), (pb[0], ya, pb[1]), (pb[0], yb, pb[1]), (pa[0], yb, pa[1])]
nn = normal or _wall_normal(a, b)
self.quad(_orient(p, nn), tx, coll=coll, mat=mat, tint=tint)
def floor(self, x0, z0, x1, z1, y, tex='gras', rot='random', tint=(1, 1, 1), step=1.0, coll=True):
"""Flache Bodenflaeche (nur Oberseite)."""
self._hface(x0, z0, x1, z1, y, tex, True, step, rot, tint, coll)
def box(self, x0, z0, x1, z1, y0, y1, top='gras', side='erde', side_top=None, bottom=None,
tint=(1, 1, 1), rot='random', sides='nesw', solid=True, coll=True, mat=0, step=1.0, side_coll=None):
"""Quader. side_top: Textur fuer die oberste Reihe der Seitenwaende (z.B. 'graskante').
bottom: Unterseite zeichnen (fuer schwebende Plattformen). sides: welche Seiten (n=z0,
s=z1, w=x0, e=x1)."""
x0, x1 = min(x0, x1), max(x0, x1)
z0, z1 = min(z0, z1), max(z0, z1)
y0, y1 = min(y0, y1), max(y0, y1)
if top:
self._hface(x0, z0, x1, z1, y1, top, True, step, rot, tint, coll, mat)
if bottom:
self._hface(x0, z0, x1, z1, y0, bottom, False, step, 0, tint, coll, mat)
sc = coll if side_coll is None else side_coll
if side:
# Waende so, dass die Aussenseite rechts liegt
if 'n' in sides:
self._vface((x1, z0), (x0, z0), y0, y1, side, side_top, step, tint, sc, mat, (0, 0, -1))
if 's' in sides:
self._vface((x0, z1), (x1, z1), y0, y1, side, side_top, step, tint, sc, mat, (0, 0, 1))
if 'w' in sides:
self._vface((x0, z0), (x0, z1), y0, y1, side, side_top, step, tint, sc, mat, (-1, 0, 0))
if 'e' in sides:
self._vface((x1, z1), (x1, z0), y0, y1, side, side_top, step, tint, sc, mat, (1, 0, 0))
if solid:
self.solids.append((x0, y0, z0, x1, y1, z1))
def platform(self, x, z, w, d, y, top='gras', side='erde', thick=0.5, side_top='graskante',
bottom='erde', **kw):
"""Schwebende Plattform: Oberkante y, Ecke (x,z), Groesse w x d."""
self.box(x, z, x + w, z + d, y - thick, y, top, side, side_top=side_top, bottom=bottom, **kw)
def pillar(self, x, z, y0, y1, size=1.0, side='stein', top='stein', **kw):
"""Saeule mit Mittelpunkt (x,z)."""
h = size / 2
self.box(x - h, z - h, x + h, z + h, y0, y1, top, side, **kw)
def ramp(self, x0, z0, x1, z1, ya, yb, direction='+x', top='sand', side='erde', base=None,
tint=(1, 1, 1), step=1.0, coll=True):
"""Rampe ueber dem Rechteck; steigt in 'direction' (+x,-x,+z,-z) von ya auf yb.
Unterkante 'base' (Standard: ya - 0.5); Seiten und Stirnflaeche werden geschlossen."""
x0, x1 = min(x0, x1), max(x0, x1)
z0, z1 = min(z0, z1), max(z0, z1)
base = (min(ya, yb) - 0.5) if base is None else base
ax = direction[1]
sgn = 1 if direction[0] == '+' else -1
def h(x, z):
t = (x - x0) / (x1 - x0) if ax == 'x' else (z - z0) / (z1 - z0)
if sgn < 0:
t = 1 - t
return ya + (yb - ya) * t
xs, zs = _cuts(x0, x1, step), _cuts(z0, z1, step)
for xa, xb in zip(xs, xs[1:]):
for za, zb in zip(zs, zs[1:]):
p = [(xa, h(xa, za), za), (xb, h(xb, za), za), (xb, h(xb, zb), zb), (xa, h(xa, zb), zb)]
self.quad(_orient(p, (0, 1, 0)), top, coll=coll, tint=tint)
# Seiten: Trapeze in Streifen
edges = [((x1, z0), (x0, z0), (0, 0, -1)), ((x0, z1), (x1, z1), (0, 0, 1)),
((x0, z0), (x0, z1), (-1, 0, 0)), ((x1, z1), (x1, z0), (1, 0, 0))]
for a, b, nn in edges:
Ln = math.hypot(b[0] - a[0], b[1] - a[1])
n = max(1, int(round(Ln / step)))
for i in range(n):
pa = (a[0] + (b[0] - a[0]) * i / n, a[1] + (b[1] - a[1]) * i / n)
pb = (a[0] + (b[0] - a[0]) * (i + 1) / n, a[1] + (b[1] - a[1]) * (i + 1) / n)
ha, hb = h(*pa), h(*pb)
if max(ha, hb) - base < 1e-6:
continue
p = [(pa[0], base, pa[1]), (pb[0], base, pb[1]), (pb[0], hb, pb[1]), (pa[0], ha, pa[1])]
self.quad(_orient(p, nn), side, coll=coll, tint=tint)
lo = min(ya, yb)
self.solids.append((x0, base, z0, x1, lo, z1))
def stairs(self, x0, z0, x1, z1, y0, y1, n, direction='+x', top='stein', side='stein2', base=None,
walkable=True, tint=(1, 1, 1)):
"""Treppe mit n Stufen auf dem Rechteck, steigt in 'direction' (+x,-x,+z,-z) von y0 auf y1.
walkable=True: Kollision als unsichtbare Schraege (Lama kann hochlaufen; die Engine hat
keine Stufen-Logik, schon 0.12 hohe Kanten blockieren), sonst echte Stufen (springen)."""
x0, x1 = min(x0, x1), max(x0, x1)
z0, z1 = min(z0, z1), max(z0, z1)
base = (y0 - 0.5) if base is None else base
ax = direction[1]
neg = direction[0] == '-'
# in lokale Koordinaten u (Steigrichtung 0..1) / v (quer 0..1) abbilden
def P(u, v, y):
if neg:
u = 1 - u
return ((x0 + (x1 - x0) * u, y, z0 + (z1 - z0) * v) if ax == 'x'
else (x0 + (x1 - x0) * v, y, z0 + (z1 - z0) * u))
def nrm(du, dv):
if neg:
du = -du
return (du, 0, dv) if ax == 'x' else (dv, 0, du)
rc = not walkable
for i in range(n):
u0, u1 = i / n, (i + 1) / n
yt = y0 + (y1 - y0) * (i + 1) / n
yl = base if i == 0 else y0 + (y1 - y0) * i / n
last = i == n - 1
# Trittflaeche
q = [P(u0, 0, yt), P(u1, 0, yt), P(u1, 1, yt), P(u0, 1, yt)]
self.quad(_orient(q, (0, 1, 0)), top, coll=(rc or last), tint=tint)
# Setzstufe (Stirnseite), Fuss bis base
q = [P(u0, 0, base), P(u0, 1, base), P(u0, 1, yt), P(u0, 0, yt)]
self.quad(_orient(q, nrm(-1, 0)), side, coll=rc, tint=tint)
# Seitenwaende dieser Stufe (bis base)
for v, dv in ((0, -1), (1, 1)):
q = [P(u0, v, base), P(u1, v, base), P(u1, v, yt), P(u0, v, yt)]
self.quad(_orient(q, nrm(0, dv)), side, coll=True, tint=tint)
self.solids.append(_aabb([P(u0, 0, base), P(u1, 1, yt)]))
# Rueckwand
q = [P(1, 0, base), P(1, 1, base), P(1, 1, y1), P(1, 0, y1)]
self.quad(_orient(q, nrm(1, 0)), side, coll=True, tint=tint)
if walkable:
ue = (n - 1) / n if n > 1 else 1.0
q = [P(0, 0, y0), P(ue, 0, y1), P(ue, 1, y1), P(0, 1, y0)]
self.collider(q)
def fence(self, pts, y, height=0.6, thick=0.12, post='holz_dunkel', rail='holz_maser', posts_every=1.0):
"""Zaun (Pfosten + Querlatte) entlang Punktliste [(x,z),...] auf Hoehe y. Mit Kollision."""
for (ax, az), (bx, bz) in zip(pts, pts[1:]):
L = math.hypot(bx - ax, bz - az)
n = max(1, int(round(L / posts_every)))
for i in range(n + 1):
px, pz = ax + (bx - ax) * i / n, az + (bz - az) * i / n
self.box(px - thick, pz - thick, px + thick, pz + thick, y, y + height, top=post, side=post, rot=0)
# Latte als duenner Quader
t = thick * 0.6
yb = y + height * 0.55
if abs(bx - ax) >= abs(bz - az):
self.box(min(ax, bx), az - t, max(ax, bx), az + t, yb, yb + 0.15, top=rail, side=rail, rot=0,
solid=False)
else:
self.box(ax - t, min(az, bz), ax + t, max(az, bz), yb, yb + 0.15, top=rail, side=rail, rot=0,
solid=False)
def tree(self, x, z, y, height=2.5, crown=1.4, trunk='rinde', leaves='gras_dunkel', trunk_size=None):
"""Einfacher Klotz-Baum (Stamm + zwei Kronen-Wuerfel)."""
self.pillar(x, z, y, y + height, size=trunk_size or max(0.15, crown * 0.25), side=trunk, top=trunk)
c = crown / 2
self.box(x - c, z - c, x + c, z + c, y + height - 0.2, y + height + crown * 0.7, top=leaves, side=leaves,
bottom=leaves, coll=True)
c2 = c * 0.6
self.box(x - c2, z - c2, x + c2, z + c2, y + height + crown * 0.7, y + height + crown * 1.1, top=leaves,
side=leaves, coll=True)
def sea(self, x0, z0, x1, z1, y=-2.0, tex='meer', kill=None, step=4.0):
"""Wasser-/Grundflaeche ohne Kollision (grob unterteilt, step) + toedliche Ebene knapp
darueber. kill=(x0,z0,x1,z1): Bereich der Todesebene (Standard: ganze Flaeche, False: keine).
Die Todesebene wird nur in Zellen ohne durchgehenden Boden angelegt."""
self._hface(x0, z0, x1, z1, y, tex, True, step, 0, (1, 1, 1), False)
if kill is not False:
self.kill = (y + 0.05,) + tuple(kill or (x0, z0, x1, z1))
def obj(self, path, offset=(0, 0, 0), scale=1.0, tex='stein', coll=True):
"""OBJ-Datei (Spielkoordinaten, y oben). 'usemtl tex_N' bzw. Texturname als Material."""
V, cur = [], tex
for line in open(path, encoding='latin1'):
p = line.split()
if not p:
continue
if p[0] == 'v':
V.append(tuple(float(c) * scale + o for c, o in zip(p[1:4], offset)))
elif p[0] == 'usemtl':
m = p[1]
cur = int(m[4:]) if m.startswith('tex_') else (m if (m in TEX or m in FLAT) else tex)
elif p[0] == 'f':
ids = [int(t.split('/')[0]) for t in p[1:]]
pts = [V[i - 1 if i > 0 else len(V) + i] for i in ids]
if len(pts) == 4:
self.quad(pts, cur, coll=coll)
else:
for k in range(1, len(pts) - 1):
self.quad([pts[0], pts[k], pts[k + 1]], cur, coll=coll)
# ------------------------------------------------------------ Hoehe
def height_at(self, x, z, below=1e9):
"""Hoechste begehbare Flaeche bei (x,z) unterhalb 'below' (oder None)."""
best = None
for q in self.quads:
if not q.coll:
continue
p = q.p
xs = [a[0] for a in p]; zs = [a[2] for a in p]
if not (min(xs) - 1e-6 <= x <= max(xs) + 1e-6 and min(zs) - 1e-6 <= z <= max(zs) + 1e-6):
continue
n = _norm(_cross(_sub(p[1], p[0]), _sub(p[2], p[0])))
if abs(n[1]) < 0.4:
continue
for tri in ([p[0], p[1], p[2]], [p[0], p[2], p[3]]) if len(p) == 4 else ([p],):
if _in_tri_xz(x, z, tri):
a = tri[0]
y = a[1] - (n[0] * (x - a[0]) + n[2] * (z - a[2])) / n[1]
if y <= below + 1e-6 and (best is None or y > best):
best = y
return best
# ------------------------------------------------------------ Objekte
def strat(self, name, x, z, y=None, rot=None, params=None, wp='relative', variant=0, hidden=False):
"""Objekt platzieren. y=None: auf die hoechste Flaeche bei (x,z) stellen.
rot: Grad (None = wie im Original). params: Liste u32 (None = Original).
wp: 'relative' (Wegpunktkette des Originals mitverschieben, y auf Boden),
'here' (alle Wegpunkte auf das Objekt), 'none' (-1) oder Liste [(x,z[,y[,rot]]),...]."""
if name not in self.protos:
raise KeyError('Strat-Typ %r nicht im Basislevel (vorhanden: %s)' % (name, ', '.join(self.types())))
if y is None:
y = self.height_at(x, z)
if y is None:
y = 0.0
d = dict(name=name, x=x, y=y, z=z, rot=rot, params=params, wp=wp, variant=variant, hidden=hidden)
self.strats.append(d)
return d
def player(self, x, z, rot=0, y=None, cam_dist=8.0):
"""Spielerstart. Der erste Wegpunkt der Lama-Kette ist (im Original) die Startposition der
Kamera -> hinter das Lama legen (Abstand cam_dist), die uebrigen auf das Lama."""
d = self.strat('Llama', x, z, y, rot, wp='here')
a = math.radians(rot or 0)
cx, cz = x - cam_dist * math.sin(a), z - cam_dist * math.cos(a)
n = 1
k = self.protos['Llama'][0][13]
while k >= 0 and self.map['nodes'][k][0] and n < 200:
k = self.map['nodes'][k][0]; n += 1
d['wp'] = [(cx, cz, d['y'] - 0.4, rot or 0)] + [(x, z, d['y'], rot or 0)] * (n - 1)
return d
def checkpoint(self, x, z, rot=None, y=None):
return self.strat('CheckPointPost', x, z, y, rot)
def exit(self, x, z, rot=None, y=None, params=None):
return self.strat('LevelDoor', x, z, y, rot, params)
def coins(self, pts, y_off=0.0, name='Coin'):
for p in pts:
yy = self.height_at(p[0], p[1])
self.strat(name, p[0], p[1], (yy or 0) + y_off)
# ------------------------------------------------------------ Bauen
def build(self, out, verbose=True):
geo = self._geometry()
models, tiles = geo
text = self._text()
trak = G.build_trak(models)
mp = self._map(models, tiles)
ch = [[t, c] for t, c in self.ch]
G.wad_set(ch, 'TRAK', trak)
G.wad_set(ch, 'MAP', mp)
G.wad_set(ch, 'TEXT', text)
nm = G.wad_get(ch, 'NAME')
if nm is not None and self.name:
G.wad_set(ch, 'NAME', nm[:4] + self.name.encode('latin1') + b'\0')
G.wad_write(out, ch)
# Gegenprobe: MAP parst wieder vollstaendig
chk = M.parse(mp, self.wf, [len(m.verts) for m in models])
assert not chk['rest'] and len(chk['strats']) == len(self._srecs)
if verbose:
nf = sum(len(m.faces) for m in models)
nc = sum(len(m.coll[0]) for m in models)
print('%s: %d Modelle, %d Kacheln, %d Dreiecke, %d Kollisionspolygone, %d Strats, %d Wegpunkte, '
'TRAK %d KB, WAD %d KB' % (out, len(models), len(tiles), nf, nc, len(self._srecs),
len(self._nodes), len(trak) // 1024, os.path.getsize(out) // 1024))
return out
# -- Beleuchtung
def _light(self, P, N, tint):
"""P,N: (n,3) Spielkoordinaten; liefert (n,3) Farbe 1.0 = Texturfarbe."""
s = self.sun
az, el = math.radians(s['azimuth']), math.radians(s['elevation'])
L = np.array([math.cos(el) * math.sin(az), math.sin(el), math.cos(el) * math.cos(az)])
sky = np.array(s['sky']); gnd = np.array(s['ground']); sc = np.array(s['color'])
N0 = N
ndl = np.clip(N @ L, 0, None)
hemi = (N[:, 1:2] * 0.5 + 0.5)
amb = gnd + (sky - gnd) * hemi
B = np.array(self.solids, float) if self.solids else np.zeros((0, 6))
# Strahlen: Sonne + Halbkugel
dirs = _hemi_dirs()
shadow = np.ones(len(P))
ao = np.ones(len(P))
CH = 256
order = np.lexsort((P[:, 2] // 4, P[:, 0] // 4)) # raeumlich sortieren -> kleine Chunks
P, N = P[order], N[order]
res_shadow, res_ao = np.ones(len(P)), np.ones(len(P))
ndl = ndl[order]
topY = B[:, 4].max() if len(B) else 0
for i in range(0, len(P), CH):
p = P[i:i + CH] + N[i:i + CH] * 0.03
n = N[i:i + CH]
if len(B):
lo, hi = p.min(0), p.max(0)
r = s['ao_radius']
near = B[((B[:, 0] <= hi[0] + r) & (B[:, 3] >= lo[0] - r) & (B[:, 1] <= hi[1] + r) &
(B[:, 4] >= lo[1] - r) & (B[:, 2] <= hi[2] + r) & (B[:, 5] >= lo[2] - r))]
tm = max(0.0, (topY - lo[1]) / max(L[1], 0.05))
e = L * tm
slo, shi = np.minimum(lo, lo + e), np.maximum(hi, hi + e)
far = B[((B[:, 0] <= shi[0]) & (B[:, 3] >= slo[0]) & (B[:, 1] <= shi[1]) &
(B[:, 4] >= slo[1]) & (B[:, 2] <= shi[2]) & (B[:, 5] >= slo[2]))]
lit = ndl[i:i + CH] > 0
hit = _ray_boxes(p, np.broadcast_to(L, p.shape), far, 60.0) if len(far) else np.zeros(len(p), bool)
res_shadow[i:i + CH] = np.where(lit & hit, 0.0, 1.0)
# AO: Richtungen in der Halbkugel um n
w = np.clip(n @ dirs.T, 0, None) # (c, R)
occ = np.zeros(len(p))
for r in range(len(dirs)):
m = w[:, r] > 0.05
if not m.any():
continue
if not len(near):
continue
h = _ray_boxes(p[m], np.broadcast_to(dirs[r], p[m].shape), near, s['ao_radius'])
occ[np.where(m)[0]] += h * w[m, r]
res_ao[i:i + CH] = 1 - s['ao'] * occ / np.maximum(w.sum(1), 1e-6)
shadow = np.ones(len(P)); ao = np.ones(len(P))
shadow[order] = res_shadow; ao[order] = res_ao
ndl = np.clip(N0 @ L, 0, None)
sh = 1 - (1 - shadow) * (1 - s['shadow'])
col = amb * ao[:, None] + sc * (s['strength'] * ndl * sh)[:, None] * (0.6 + 0.4 * ao[:, None])
return col * np.asarray(tint)
# -- Kacheln/Modelle
def _geometry(self):
BAND, B0 = 10.0, -3.0 # Hoehenbaender je Kachel: [-3,7), [7,17), ...
MARGIN = 0.3
tris_by_tile = {}
coll_by_tile = {}
# Dreiecke (Exportraum) + Vertexfarben vorbereiten
allv, alln, allt = [], [], []
faces = []
for q in self.quads:
pts = q.p
n = tuple(-c for c in _norm(_cross(_sub(pts[1], pts[0]), _sub(pts[2], pts[0])))) # aussen
if len(pts) == 4:
tri = [((0, 1, 2), 0x820), ((2, 3, 0), 0x830)]
else:
tri = [((0, 1, 2), 0x820)]
for ids, fl in (tri if q.render else []):
P = [pts[k] for k in ids]
if _area(P) < 1e-8:
continue
cen = [sum(c[k] for c in P) / 3 for k in range(3)]
key = (math.floor(cen[0]), math.floor(cen[2]), math.floor((cen[1] - B0) / BAND))
vi = []
for p in P:
vi.append(len(allv)); allv.append(p); alln.append(n); allt.append(q.tint)
faces.append((key, vi, q.tex, fl | (0x400 if q.double else 0), q.shade))
if q.coll:
xs = [a[0] for a in pts]; zs = [a[2] for a in pts]
cy = sum(a[1] for a in pts) / len(pts)
band = math.floor((cy - B0) / BAND)
# Boeden: nur ueberdeckte Zellen; Waende/Schraegen: auch Nachbarzellen (Spielerradius)
mg = 0.0 if abs(n[1]) > 0.95 else MARGIN
for cx in range(math.floor(min(xs) - mg), math.floor(max(xs) + mg - 1e-6) + 1):
for cz in range(math.floor(min(zs) - mg), math.floor(max(zs) + mg - 1e-6) + 1):
coll_by_tile.setdefault((cx, cz, band), []).append((pts, q.mat))
P = np.array(allv, float); N = np.array(alln, float); T = np.array(allt, float)
if len(P):
key = np.round(np.hstack([P, N]), 4)
uk, inv = np.unique(key, axis=0, return_inverse=True)
C = self._light(uk[:, :3], uk[:, 3:], np.ones((len(uk), 3)))[inv.reshape(-1)] * T
else:
C = np.zeros((0, 3))
for key, vi, tex, fl, shade in faces:
tris_by_tile.setdefault(key, []).append((vi, tex, fl, shade))
models, tiles, cache = [], [], {}
kill_model = None
texinfo = lambda t: (self.texs[t]['page'], self.texs[t]['flags']) if t < len(self.texs) else (-1, 0x105)
for key in sorted(set(tris_by_tile) | set(coll_by_tile)):
cx, cz, band = key
if not (0 <= cx < self.W and 0 <= cz < self.H):
raise SystemExit('Geometrie ausserhalb des Rasters %dx%d: Zelle %d,%d' % (self.W, self.H, cx, cz))
ox, oy, oz = cx + 0.5, B0 + band * BAND + BAND / 2, cz + 0.5
m = G.Model(); m.dirty = True
vmap, cols, nacc = {}, [], []
flist = sorted(tris_by_tile.get(key, []), key=lambda f: (texinfo(f[1]), f[1]))
fdata = []
for vi, tex, fl, shade in flist:
ids = []
for k in vi:
lp = tuple(round(c, 5) for c in to_exp((P[k][0] - ox, P[k][1] - oy, P[k][2] - oz)))
col = C[k] if shade else np.ones(3)
cu = _col_u32(col)
kk = (lp, tuple(float(x) + 0.0 for x in np.round(N[k], 3)))
if kk not in vmap:
vmap[kk] = len(m.verts)
m.verts.append(lp + tuple(to_exp(N[k])))
cols.append(cu)
ids.append(vmap[kk])
if len(set(ids)) < 3:
continue
fdata.append((ids, tex, fl))
prev = None
for ids, tex, fl in fdata:
plane = G.face_plane(*[m.verts[i][:3] for i in ids])
st = texinfo(tex)
if st != prev or len(m.faces) % 256 == 0:
fl |= 0x8
prev = st
m.faces.append(dict(flags=fl, v=tuple(ids), tex=tex, pad=0, plane=plane))
for pts, mat in coll_by_tile.get(key, []):
loc = [to_exp((p[0] - ox, p[1] - oy, p[2] - oz)) for p in pts]
if max(abs(c) for p in loc for c in p) > 7.9:
continue
recs = []
r = G.coll_from_polygon(loc, mat)
if r is not None:
recs = [r]
for r in recs:
if struct.unpack_from('<b', r, 3)[0] < 0:
r = bytes([r[0] | 4]) + r[1:]
m.coll[0].append(r)
if not m.faces and not m.coll[0]:
continue
# Box der Kachel muss die Kollisionspolygone umfassen (unbenutzte Hilfsvertices)
cpts = [to_exp((p[0] - ox, p[1] - oy, p[2] - oz)) for pts, _ in coll_by_tile.get(key, []) for p in pts]
if cpts:
lo = [min(c[k] for c in cpts) for k in range(3)]
hi = [max(c[k] for c in cpts) for k in range(3)]
if m.verts:
lo = [min(lo[k], min(v[k] for v in m.verts)) for k in range(3)]
hi = [max(hi[k], max(v[k] for v in m.verts)) for k in range(3)]
vlo = [min(v[k] for v in m.verts) for k in range(3)]
vhi = [max(v[k] for v in m.verts) for k in range(3)]
need = any(lo[k] < vlo[k] - 1e-4 or hi[k] > vhi[k] + 1e-4 for k in range(3))
else:
need = True
if need:
for c in (lo, hi):
m.verts.append((round(c[0], 5), round(c[1], 5), round(c[2], 5), 0.0, 1.0, 0.0))
cols.append(0x01808080)
if len(m.verts) > 4000:
raise SystemExit('Kachel %s zu gross (%d Vertices)' % (key, len(m.verts)))
if not m.verts: # reine Kollisionskachel braucht keine Vertices
pass
h = _model_key(m)
if h not in cache:
cache[h] = len(models)
models.append(m)
tiles.append(dict(x=ox, y=oy, z=-oz, rot=0, model=cache[h], cols=cols))
# Todesebene (Original-Modell 0 des Dorfes: unsichtbar, Material 0x1e, y_lokal = -2.75)
if self.kill:
ky, x0, z0, x1, z1 = self.kill
km = self.trak0[0]
kill_model = len(models)
models.append(km)
ty = ky + 2.75
# nur Zellen ohne begehbaren Boden (spart Kacheln; unter Boden faellt man nicht)
covered = self._covered_cells()
for cx in range(max(0, math.floor(x0)), min(self.W, math.ceil(x1))):
for cz in range(max(0, math.floor(z0)), min(self.H, math.ceil(z1))):
if (cx, cz) in covered:
continue
tiles.append(dict(x=cx + 0.5, y=ty, z=-(cz + 0.5), rot=0, model=kill_model,
cols=[0x01404040] * len(km.verts)))
return models, tiles
def _covered_cells(self):
"""Zellen, die vollstaendig von nach oben zeigender Kollision (Boden) bedeckt sind."""
area = {}
for q in self.quads:
if not q.coll or len(q.p) != 4:
continue
p = q.p
n = _norm(_cross(_sub(p[1], p[0]), _sub(p[2], p[0])))
if abs(n[1]) < 0.7:
continue
xs = [a[0] for a in p]; zs = [a[2] for a in p]
for cx in range(math.floor(min(xs)), math.ceil(max(xs))):
for cz in range(math.floor(min(zs)), math.ceil(max(zs))):
ov = max(0, min(max(xs), cx + 1) - max(min(xs), cx)) * max(0, min(max(zs), cz + 1) - max(min(zs), cz))
area[(cx, cz)] = area.get((cx, cz), 0) + ov
return {k for k, v in area.items() if v > 0.999}
def _text(self):
d = G.wad_get(self.ch, 'TEXT')
if not self.flats_used:
return d
o = 0
npal = struct.unpack_from('<I', d, o)[0]; o += 4
for _ in range(npal):
n = struct.unpack_from('<I', d, o + 4)[0]; o += 8 + 3 * n
npg = struct.unpack_from('<I', d, o)[0]; o += 4
for _ in range(npg):
fl, w, h = struct.unpack_from('<3I', d, o); o += 12
if fl == 0:
o += w * h
elif fl & 0x80:
o += 4 + struct.unpack_from('<I', d, o)[0]
else:
o += w * h * 2
nt = struct.unpack_from('<I', d, o)[0]
assert nt == len(self.texs)
add = b''
for name, idx in sorted(self.flats_used.items(), key=lambda kv: kv[1]):
r, g, b = FLAT[name]
add += struct.pack('<HbbBBBB', 0x105, -1, -1, r, g, b, 0)
e = o + 4 + 8 * nt
return d[:o] + struct.pack('<I', nt + len(self.flats_used)) + d[o + 4:e] + add + d[e:]
def _map(self, models, tiles):
m = dict(self.map)
m['tiles'] = [(t['x'], t['y'], t['z'], 0, t['model'], 0) for t in tiles]
m['tiles2'] = [tuple(v & 0xffffffff for v in (0, 0, 0, int(round(t['x'] * FX)), int(round(t['y'] * FX)),
int(round(-t['z'] * FX)))) for t in tiles]
m['tilemodel'] = [t['model'] for t in tiles]
m['alts'] = []
m['vcol'] = b''.join(struct.pack('<%dI' % len(t['cols']), *t['cols']) for t in tiles)
for t in tiles:
assert len(t['cols']) == len(models[t['model']].verts)
tab = list(m['tab32'])
tab[0] = 0xffffffff
m['tab32'] = tuple(tab)
m['grid'] = (0,) * (self.W * self.H)
# Strats + Wegpunkte
pool = list(self.pool0)
nodes = []
recs = []
orig_nodes = self.map['nodes']
for d in self.strats:
proto = list(self.protos[d['name']][d['variant']])
s = list(proto)
if d['rot'] is not None:
s[1] = deg(d['rot'])
s[3], s[4], s[5] = int(round(d['x'] * FX)), int(round(d['y'] * FX)), int(round(d['z'] * FX))
prm = d['params']
if prm is None:
if d['name'] in self.foreign_params:
prm = self.foreign_params[d['name']]
else:
prm = self.pool0[proto[8]:proto[8] + proto[7]] if proto[7] else []
if len(prm) != proto[7]:
raise ValueError('%s braucht %d Parameter, nicht %d' % (d['name'], proto[7], len(prm)))
if prm:
s[8] = len(pool)
pool.extend(int(v) & 0xffffffff for v in prm)
else:
s[8] = -1
# Wegpunkte
wp = d['wp']
chain = []
if proto[13] >= 0:
k = proto[13]
while True:
chain.append(orig_nodes[k])
nx = orig_nodes[k][0]
if not nx or len(chain) > 200:
break
k = nx
new = []
if isinstance(wp, (list, tuple)):
for i, p in enumerate(wp):
px, pz = p[0], p[1]
py = p[2] if len(p) > 2 and p[2] is not None else (self.height_at(px, pz) or d['y'])
pr = deg(p[3]) if len(p) > 3 else s[1]
# Zusatzfelder (Wartezeit/Modus o.ae.) vom Original-Wegpunkt uebernehmen
a_, b_ = (chain[i][8], chain[i][9]) if i < len(chain) else (0, 0)
new.append([0, 0, 0, pr, 0, int(px * FX), int(py * FX), int(pz * FX), a_, b_])
elif chain and wp != 'none':
ox, oy, oz = proto[3], proto[4], proto[5]
for nd in chain:
nd = list(nd)
if wp == 'here':
nd[5], nd[6], nd[7] = s[3], s[4], s[5]
else:
nx_ = _s32(nd[5]) - ox + s[3]
nz_ = _s32(nd[7]) - oz + s[5]
ny_ = _s32(nd[6]) - oy + s[4]
g = self.height_at(nx_ / FX, nz_ / FX, below=ny_ / FX + 1.0)
if g is not None and abs(g - ny_ / FX) < 2.0:
ny_ = int(g * FX)
nd[5], nd[6], nd[7] = nx_, ny_, nz_
new.append(nd)
if new:
base = len(nodes)
for i, nd in enumerate(new):
nd = list(nd)
nd[0] = base + i + 1 if i < len(new) - 1 else 0
nd[5:8] = [v & 0xffffffff for v in nd[5:8]]
nodes.append(tuple(nd))
s[13] = base
else:
s[13] = -1
s[15] = 2 if d['hidden'] else 0
s[3], s[4], s[5] = (v for v in (s[3], s[4], s[5]))
recs.append(tuple(s))
m['list1'] = pool
m['nodes'] = nodes if nodes else [tuple([0] * 10)]
m['strats'] = recs
m['strat_x'] = max(self.map['strat_x'], len(recs) + 80)
self._srecs, self._nodes = recs, nodes
return M.build(m, self.wf)
# ---------------------------------------------------------------- Hilfsfunktionen
def _s32(v):
return v - (1 << 32) if v >= 1 << 31 else v
def _cuts(a, b, step, align=True):
a, b = min(a, b), max(a, b)
if not align:
n = max(1, int(round((b - a) / step)))
return [a + (b - a) * i / n for i in range(n + 1)]
out = [a]
k = math.floor(a / step + 1e-9) + 1
while k * step < b - 1e-6:
if k * step - out[-1] > 0.05:
out.append(k * step)
k += 1
if b - out[-1] < 0.05 and len(out) > 1:
out[-1] = b
else:
out.append(b)
return out
def _wall_normal(a, b):
dx, dz = b[0] - a[0], b[1] - a[1]
n = _norm((-dz, 0, dx))
return n
def _orient(p, normal):
"""Reihenfolge so, dass die Flaeche nach 'normal' (Spielraum) zeigt. Die Engine rechnet im
Exportraum (z gespiegelt): dort muss (p1-p0)x(p2-p0) nach aussen zeigen, im Spielraum also
nach innen (Spiegelung dreht den Umlaufsinn)."""
n = _cross(_sub(p[1], p[0]), _sub(p[2], p[0]))
if sum(a * b for a, b in zip(n, normal)) > 0:
# spiegeln, Ecke 0 bleibt unten-links -> [1,0,3,2]
p = [p[1], p[0], p[3], p[2]] if len(p) == 4 else [p[1], p[0], p[2]]
return p
def _aabb(pts):
xs = [p[0] for p in pts]; ys = [p[1] for p in pts]; zs = [p[2] for p in pts]
return (min(xs), min(ys), min(zs), max(xs), max(ys), max(zs))
def _area(P):
c = _cross(_sub(P[1], P[0]), _sub(P[2], P[0]))
return math.sqrt(sum(x * x for x in c)) / 2
def _in_tri_xz(x, z, t):
(ax, _, az), (bx, _, bz), (cx, _, cz) = t
d1 = (x - bx) * (az - bz) - (ax - bx) * (z - bz)
d2 = (x - cx) * (bz - cz) - (bx - cx) * (z - cz)
d3 = (x - ax) * (cz - az) - (cx - ax) * (z - az)
neg = d1 < -1e-9 or d2 < -1e-9 or d3 < -1e-9
pos = d1 > 1e-9 or d2 > 1e-9 or d3 > 1e-9
return not (neg and pos)
def _col_u32(c):
r, g, b = (max(0, min(255, int(round(v * 128)))) for v in c)
return (1 << 24) | (b << 16) | (g << 8) | r
def _model_key(m):
h = hashlib.sha1()
for v in m.verts:
h.update(struct.pack('<6f', *v))
for f in m.faces:
h.update(struct.pack('<5H', f['flags'], *f['v'], f['tex']))
for r in m.coll[0]:
h.update(r)
return h.hexdigest()
_HEMI = None
def _hemi_dirs():
global _HEMI
if _HEMI is None:
d = []
# Fibonacci-Kugel, 24 Richtungen
n = 24
for i in range(n):
y = 1 - 2 * (i + 0.5) / n
r = math.sqrt(1 - y * y)
a = i * math.pi * (3 - math.sqrt(5))
d.append((r * math.cos(a), y, r * math.sin(a)))
_HEMI = np.array(d)
return _HEMI
def _ray_boxes(p, d, B, tmax):
"""p,d: (n,3); B: (b,6). Liefert bool (n,): trifft irgendeine Box mit 0<t<tmax."""
if len(p) == 0:
return np.zeros(0, bool)
with np.errstate(divide='ignore', invalid='ignore'):
inv = 1.0 / np.where(np.abs(d) < 1e-9, 1e-9, d)
lo = B[None, :, 0:3]; hi = B[None, :, 3:6]
t1 = (lo - p[:, None, :]) * inv[:, None, :]
t2 = (hi - p[:, None, :]) * inv[:, None, :]
tn = np.minimum(t1, t2).max(2)
tf = np.maximum(t1, t2).min(2)
hit = (tf >= np.maximum(tn, 1e-4)) & (tn < tmax)
return hit.any(1)
# ---------------------------------------------------------------- CLI
def load_script(path):
spec = importlib.util.spec_from_file_location('levelscript', path)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
def main():
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument('level', nargs='?')
ap.add_argument('out', nargs='?')
ap.add_argument('--base', default=BASE)
ap.add_argument('--install', action='store_true', help='als TestLevel (T1L9M001.wad) installieren')
ap.add_argument('--types', action='store_true')
ap.add_argument('--textures', metavar='PNG')
ap.add_argument('--set', action='append', default=[], help='Variable fuer das Levelskript, z.B. --set teil=a')
a = ap.parse_args()
if a.types:
L = Level(a.base)
for n in L.types():
p = L.protos[n][0]
print('%-28s Varianten %d Parameter %d Wegpunkte %s' % (n, len(L.protos[n]), p[7],
'ja' if p[13] >= 0 else '-'))
return
if a.textures:
import subprocess
names = {v: k for k, v in TEX.items()}
subprocess.run([sys.executable, os.path.join(HERE, 'texsheet.py'), a.base, a.textures], check=True)
for i in sorted(names):
print('%4d %s' % (i, names[i]))
return
if not (a.level and a.out):
ap.error('LEVEL.py und OUT.wad angeben')
mod = load_script(a.level)
opts = dict(kv.split('=', 1) for kv in a.set)
L = Level(a.base, **getattr(mod, 'LEVEL_ARGS', {}))
L.opts = opts
mod.build(L)
L.build(a.out)
if hasattr(mod, 'report'):
mod.report(L)
if a.install:
import shutil
if not os.path.exists(MODWAD + '.orig'):
shutil.copy2(MODWAD, MODWAD + '.orig')
shutil.copyfile(a.out, MODWAD)
print('installiert ->', MODWAD)
kapitel_links()
def kapitel_links(n=49):
"""Kapitel 2..n von Welt 9 als Symlinks auf Kapitel 1 (WAD + Ladebild T1%d%d.COR) in der Mod.
Grund: jeder Ausgang (LevelDoor) schaltet im hoechsten freien Kapitel das naechste frei;
fehlt dessen WAD, stuerzt das Spiel bei der Auswahl ab ("Couldnt load wad")."""
wd = os.path.dirname(MODWAD)
mod = os.path.dirname(wd)
neu = 0
for k in range(2, n + 1):
for d, name, ziel in ((wd, 'T1L9M%03d.wad' % k, 'T1L9M001.wad'), (mod, 'T19%d.COR' % k, 'T191.COR')):
p = os.path.join(d, name)
if not os.path.lexists(p) and os.path.exists(os.path.join(d, ziel)):
os.symlink(ziel, p)
neu += 1
if neu:
import subprocess
print('%d Kapitel-Links angelegt -> lama-mod an' % neu)
subprocess.run([os.path.expanduser('~/.local/bin/lama-mod'), 'an'], check=True,
stdout=subprocess.DEVNULL)
if __name__ == '__main__':
main()
+192
View File
@@ -0,0 +1,192 @@
#!/usr/bin/env python3
"""Testlevel bauen: in einem leeren Bereich des MAP-Rasters eine Bodenplatte aus Kopien
eines flachen Boden-Tiles anlegen, darauf je einen Strat (Objekt) pro Typ im Raster
aufstellen, den Spieler (Strat "Llama") an den Anfang setzen und einen kleinen
Parkour (schwebende Bodenplatten, treppauf) anhängen. Geometrie/Texturen/Strat-Code
(TRAK/STPC/TEXT …) bleiben die des Quell-Levels; nur der MAP-Chunk wird neu geschrieben.
make_testlevel.py quelle.wad ziel.wad [--keep-original] [--spacing 3] [--name "TestLevel"]
--keep-original : Original-Strats behalten (sonst nur Spieler + Raster + Cheats)
Strat-Typen werden per Name erkannt (strats.py); Parameter/Typ-Felder werden von der
ersten Original-Instanz kopiert, Wegpunkt-Index bleibt (zeigt ggf. auf Original-Wegpunkte).
"""
import argparse
import os
import struct
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import mapchunk as M # noqa: E402
import strats as S # noqa: E402
import wadtool # noqa: E402
FX = 4096
# nicht ins Raster: Kamera/Cutscene/Trigger-Zonen, Spieler selbst
SKIP_PREFIX = ('CutScene', 'Camera', 'MainMenu', 'Options', 'Title', 'TheNewGroove', 'Force',
'EndGameTrigger', 'RiverTriggerBox', 'FallOffNoHurt')
KEEP_ORIGINAL = ('CheatTeleport', 'CheatCharge')
PLAYER = 'Llama'
def trak_models(trak):
n = struct.unpack_from('<I', trak, 0)[0]
o = 4 + n * 0x84
out = []
for i in range(n):
h = 4 + i * 0x84
nv, nf = struct.unpack_from('<HH', trak, h + 0x6c)
q = sum(struct.unpack_from('<3H', trak, h + 0x78))
vs = [struct.unpack_from('<3f', trak, o + 0x18 * k) for k in range(nv)]
out.append(vs)
o += nv * 0x18 + nf * 0x1c + q * 0x20
return out
def find_floor_tile(m, models, force=None):
"""Häufigstes Tile, dessen Modell eine flache 1x1-Fläche ist."""
from collections import Counter
flat = set()
for i, vs in enumerate(models):
if vs and len({round(v[1], 3) for v in vs}) == 1 and \
min(v[0] for v in vs) <= -0.49 and max(v[0] for v in vs) >= 0.49 and \
min(v[2] for v in vs) <= -0.49 and max(v[2] for v in vs) >= 0.49:
flat.add(i)
c = Counter(mi for t, mi in zip(m['tiles'], m['tilemodel']) if mi in flat and not t[5])
# begehbare Böden liegen bei Modell-y = +0.25 (Strats stehen auf Tile-y + 0.25)
best = [k for k, _ in c.most_common() if abs(models[k][0][1] - 0.25) < 1e-3]
mdl = force if force is not None else (best or [c.most_common(1)[0][0]])[0]
idx = next(i for i, (t, mi) in enumerate(zip(m['tiles'], m['tilemodel'])) if mi == mdl and not t[5])
return idx, mdl, models[mdl][0][1]
def empty_rect(m, w, h):
occ = {(int(t[0]), int(-t[2])) for t in m['tiles']}
for z0 in range(m['H'] - h, -1, -1):
for x0 in range(0, m['W'] - w + 1):
if all((x, z) not in occ for x in range(x0 - 1, x0 + w + 1)
for z in range(z0 - 1, z0 + h + 1)):
return x0, z0
raise SystemExit(f'kein freier Bereich {w}x{h} im Raster {m["W"]}x{m["H"]}')
def main():
ap = argparse.ArgumentParser()
ap.add_argument('src')
ap.add_argument('dst')
ap.add_argument('--spacing', type=int, default=3)
ap.add_argument('--keep-original', action='store_true')
ap.add_argument('--name')
ap.add_argument('--floor-model', type=int, help='TRAK-Modellindex für Bodenplatten')
ap.add_argument('--y', type=float, default=None, help='Höhe der Bodenplatte (Tile-y)')
a = ap.parse_args()
ch, wf, m, lo, types = S.level_types(a.src)
models = trak_models(ch['TRAK'])
ftile, fmodel, fsurf = find_floor_tile(m, models, a.floor_model)
names = sorted(n for n in types if n != PLAYER and not n.startswith(SKIP_PREFIX)
and n not in KEEP_ORIGINAL and n != 'LevelDoor')
cols = 6
rows = (len(names) + cols - 1) // cols
sp = a.spacing
gw, gh = cols * sp + 2, rows * sp + 4 # Raster + Startstreifen
pk = 14 # Parkour-Streifen
x0, z0 = empty_rect(m, gw + pk, gh)
base = m['tiles'][ftile]
y = a.y if a.y is not None else base[1]
print(f'Boden-Tile #{ftile} Modell {fmodel} (Fläche y+{fsurf}), Bereich x{x0}..{x0 + gw + pk} '
f'z{z0}..{z0 + gh}, Höhe {y}')
def add_tile(cx, cz, ty):
m['tiles'].append((cx + 0.5, ty, -(cz + 0.5), base[3], fmodel, 0))
m['tiles2'].append((0, base[3], 0, int((cx + 0.5) * FX), int(ty * FX), int((cz + 0.5) * FX)))
m['tilemodel'].append(fmodel)
m['vcol_split'].append(m['vcol_split'][ftile])
for cx in range(x0, x0 + gw):
for cz in range(z0, z0 + gh):
add_tile(cx, cz, y)
# Parkour: Trittplatten mit Lücken, je +0.5 höher, dann Zielplattform 3x3
px, pz, py = x0 + gw + 1, z0 + 1, y
steps = []
for i in range(6):
add_tile(px, pz, py)
steps.append((px, pz, py))
px += 2
py += 0.5
if px > x0 + gw + pk - 4:
px, pz = x0 + gw + 1, pz + 3
for dx in range(3):
for dz in range(3):
add_tile(px + dx, pz + dz, py)
goal = (px + 1, pz + 1, py)
def srec(proto, cx, cz, ty, ry=None):
s = list(proto)
s[1] = proto[1] if ry is None else ry
s[3], s[4], s[5] = int((cx + 0.5) * FX), int((ty + fsurf) * FX), int((cz + 0.5) * FX)
n, pi = proto[7], proto[8]
if n:
s[8] = len(m['list1'])
m['list1'].extend(m['_list1_orig'][pi:pi + n])
else:
s[8] = -1
s[15] = 0
return tuple(s)
m['_list1_orig'] = list(m['list1'])
old = m['strats']
new = []
if a.keep_original:
new = list(old)
else:
m['list1'] = []
for s in old:
n = S.strat_name(ch['STPC'], s[6], lo)
if n in KEEP_ORIGINAL:
pi = s[8]
s = list(s)
if s[7]:
s[8] = len(m['list1'])
m['list1'].extend(m['_list1_orig'][pi:pi + s[7]])
new.append(tuple(s))
# Spieler an den Start (Rand des Rasters, Blick ins Raster)
if PLAYER in types:
new = [s for s in new if S.strat_name(ch['STPC'], s[6], lo) != PLAYER]
new.insert(0, srec(types[PLAYER][0], x0 + 1, z0 + 1, y, ry=types[PLAYER][0][1]))
placed = []
for k, nm in enumerate(names):
cx = x0 + 2 + (k % cols) * sp
cz = z0 + 3 + (k // cols) * sp
new.append(srec(types[nm][0], cx, cz, y))
placed.append((nm, cx, cz))
# Münzen auf dem Parkour, Ausgang (LevelDoor) auf der Zielplattform
if 'Coin' in types:
for (cx, cz, ty) in steps:
new.append(srec(types['Coin'][0], cx, cz, ty + 0.5))
if 'LevelDoor' in types:
new.append(srec(types['LevelDoor'][0], goal[0], goal[1], goal[2]))
m['strats'] = new
m['strat_x'] = max(m['strat_x'], len(new) + 64)
m['vcol'] = b''.join(m['vcol_split'])
for i, t in enumerate(m['tiles']): # Konsistenz
assert 0 <= int(t[0] - 0.5) < m['W'] and 0 <= int(-t[2] - 0.5) < m['H'], (i, t)
data = M.build(m, wf)
vc = M.trak_vertcounts(ch['TRAK'])
chk = M.parse(data, wf, vc)
assert len(chk['strats']) == len(new) and not chk['rest']
wch = wadtool.read(a.src)
wadtool.get(wch, 'MAP ')[1] = data
if a.name:
c = wadtool.get(wch, 'NAME')
c[1] = c[1][:4] + a.name.encode('latin1') + b'\0'
wadtool.write(a.dst, wch)
print(f'{len(m["tiles"])} Tiles, {len(new)} Strats ({len(placed)} Typen im Raster) -> {a.dst}')
for nm, cx, cz in placed:
print(f' {nm:30s} Zelle ({cx},{cz})')
if __name__ == '__main__':
main()
+47
View File
@@ -0,0 +1,47 @@
#!/usr/bin/env python3
"""MAP-Chunk eines Level-WADs ausgeben.
map_dump.py level.wad [--strats] [--tiles] [--lights] [--check]
"""
import os
import struct
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import wadtool # noqa: E402
import mapchunk as M # noqa: E402
FX = 4096.0
def load(path):
ch = dict((t.strip(), c) for t, c in wadtool.read(path))
wf = struct.unpack('<I', ch['WFPC'])[0]
vc = M.trak_vertcounts(ch['TRAK'])
return ch, wf, M.parse(ch['MAP'], wf, vc)
def main():
path = sys.argv[1]
ch, wf, m = load(path)
print(f'{os.path.basename(path)}: WFPC={wf:#010x} grid {m["W"]}x{m["H"]} tiles={len(m["tiles"])} '
f'params={len(m["list1"])} lights={len(m["lights"])} nodes={len(m["nodes"])} alts={len(m["alts"])} '
f'strats={len(m["strats"])} paths={len(m["paths"])} rest={len(m["rest"])}')
if '--tiles' in sys.argv:
for i, (t, t2, mdl) in enumerate(zip(m['tiles'], m['tiles2'], m['tilemodel'])):
print(f'T{i:4d} pos=({t[0]:6.1f},{t[1]:5.1f},{t[2]:6.1f}) rot={t[3]:4d} b={t[4]} alt={t[5]} '
f'model={mdl} t2={t2}')
if '--lights' in sys.argv:
for i, l in enumerate(m['lights']):
print('L', i, l)
if '--strats' in sys.argv:
for i, s in enumerate(m['strats']):
rx, ry, rz, x, y, z, code = s[:7]
n, pi = s[7], s[8]
prm = m['list1'][pi:pi + n] if pi >= 0 else []
print(f'S{i:3d} code@{code:#x} pos=({x / FX:7.2f},{y / FX:6.2f},{z / FX:7.2f}) '
f'rot=({rx},{ry},{rz}) params={[hex(p) for p in prm]} rest={s[9:]}')
if __name__ == '__main__':
main()
+127
View File
@@ -0,0 +1,127 @@
#!/usr/bin/env python3
"""MAP-Chunk von Level-WADs parsen/schreiben (Lama/Croc-2-Engine, PC).
Nachgebaut aus groove.exe FUN_0042ac50 (MAP-Lader). Hängt ab von
WFPC-Flags (u32 im WFPC-Chunk, Bits 0x10000, 0x10000000, 0x200000, 0x4000000, 0x10)
TRAK-Modell-Vertexzahlen (Vertexfarben-Block).
Siehe notes/objects.md.
"""
import struct
class R:
def __init__(s, d, o=0):
s.d, s.o = d, o
def u32(s):
v = struct.unpack_from('<I', s.d, s.o)[0]; s.o += 4; return v
def i32(s):
v = struct.unpack_from('<i', s.d, s.o)[0]; s.o += 4; return v
def u16(s):
v = struct.unpack_from('<H', s.d, s.o)[0]; s.o += 2; return v
def f(s, fmt):
v = struct.unpack_from('<' + fmt, s.d, s.o); s.o += struct.calcsize('<' + fmt); return v
def trak_vertcounts(trak):
"""TRAK: u32 n, n x 0x84-Header (u16 @0x6c = Vertexzahl), dann Daten."""
n = struct.unpack_from('<I', trak, 0)[0]
return [struct.unpack_from('<H', trak, 4 + i * 0x84 + 0x6c)[0] for i in range(n)]
# Feldnamen
TILE = ('x', 'y', 'z', 'rot', 'b', 'alt') # 3 f32 + 3 u32
LIGHT = '11I3IH4I2IHHI' # 90 Byte
PATHNODE = 'IIHHH5I' # 34 Byte
STRAT = '3H3iIIi4Iiih' + 'H' # 58 Byte (s.u.)
def parse(d, wfpc, vcounts):
r = R(d)
m = {}
nt, W, H = r.f('3I')
m['W'], m['H'] = W, H
m['tiles'] = [r.f('3f3I') for _ in range(nt)]
n = r.u32(); m['list1'] = list(r.f('%dI' % n)) # von Strats per Index referenziert
n = r.u32(); m['lights'] = [r.f(LIGHT) for _ in range(n)]
n = r.u32(); m['nodes'] = [r.f(PATHNODE) for _ in range(n)]
m['tab32'] = r.f('64I') # 32 x (u32,u32) -> DAT_006da350
m['grid'] = r.f('%dI' % (W * H))
m['tiles2'] = [r.f('6I') for _ in range(nt)]
m['tilemodel'] = list(r.f('%dI' % nt))
alts = []
if wfpc & 0x10000:
n = r.u32(); alts = [r.f('5I') for _ in range(n)]
m['alts'] = alts
# Vertexfarben: je Tile nv u32 (Byte3 = Frames), ggf. weitere Frames; dann Alt-Modell
altidx = [i for i, t in enumerate(m['tiles']) if t[5]]
altmap = dict(zip(altidx, alts))
start = r.o
split = [] # Vertexfarben-Bytes je Tile (inkl. Alt-Modell)
for i in range(nt):
t0 = r.o
for mdl in [m['tilemodel'][i]] + ([altmap[i][4]] if i in altmap else []):
nv = vcounts[mdl]
cols = r.f('%dI' % nv)
fr = max([c >> 24 for c in cols] + [0])
if fr > 1:
r.o += 4 * nv * (fr - 1)
split.append(d[t0:r.o])
m['vcol'] = d[start:r.o]
m['vcol_split'] = split
ns, x = r.f('2I')
m['strat_x'] = x
m['strats'] = [r.f(STRAT) for _ in range(ns)]
paths = []
if wfpc & 0x200000:
np_ = r.u32()
for _ in range(np_):
nsub = r.u32(); subs = []
for _ in range(nsub):
cnt, fl = r.f('2H'); pts = []
for _ in range(cnt):
a = r.f('2I')
c = r.f('I') if wfpc & 0x4000000 else ()
pts.append(a + c + r.f('2H'))
subs.append((fl, pts))
paths.append(subs)
m['paths'] = paths
m['x200'] = r.u32() if wfpc & 0x10 else None
m['last'] = r.u16()
m['rest'] = d[r.o:]
return m
def build(m, wfpc):
"""Gegenstück zu parse(); m['vcol'] muss zu tilemodel/alts passen (Rohbytes)."""
P = struct.pack
o = [P('<3I', len(m['tiles']), m['W'], m['H'])]
o += [P('<3f3I', *t) for t in m['tiles']]
o.append(P('<I', len(m['list1'])) + P('<%dI' % len(m['list1']), *m['list1']))
o.append(P('<I', len(m['lights'])) + b''.join(P('<' + LIGHT, *x) for x in m['lights']))
o.append(P('<I', len(m['nodes'])) + b''.join(P('<' + PATHNODE, *x) for x in m['nodes']))
o.append(P('<64I', *m['tab32']))
o.append(P('<%dI' % len(m['grid']), *m['grid']))
o += [P('<6I', *t) for t in m['tiles2']]
o.append(P('<%dI' % len(m['tilemodel']), *m['tilemodel']))
if wfpc & 0x10000:
o.append(P('<I', len(m['alts'])) + b''.join(P('<5I', *a) for a in m['alts']))
o.append(m['vcol'])
o.append(P('<2I', len(m['strats']), m['strat_x']))
o += [P('<' + STRAT, *s) for s in m['strats']]
if wfpc & 0x200000:
o.append(P('<I', len(m['paths'])))
for subs in m['paths']:
o.append(P('<I', len(subs)))
for fl, pts in subs:
o.append(P('<2H', len(pts), fl))
for p in pts:
o.append(P('<2I', *p[:2]) + (P('<I', p[2]) if wfpc & 0x4000000 else b'') + P('<2H', *p[-2:]))
if wfpc & 0x10:
o.append(P('<I', m['x200']))
o.append(P('<H', m['last']))
o.append(m['rest'])
return b''.join(o)
+68
View File
@@ -0,0 +1,68 @@
#!/usr/bin/env python3
"""Packt ein Bild als Argonaut-.cor (Gegenstück zu cor2png.py).
Das Bild wird auf 640x480 RGB gebracht und mit dem LZ-Schema des Spiels
komprimiert:
n < 0x80 -> n Bytes Rohdaten folgen (n = 1..127)
1LLL oooo oooooooo -> kopiere L+3 Bytes (3..10) ab o Bytes (1..4095) zurück
Aufruf: png2cor.py eingabe.png ausgabe.cor (benötigt Pillow)
"""
import struct
import sys
MAGIC = bytes.fromhex('1798af89fe42d112')
MAXOFF, MINLEN, MAXLEN = 0x0FFF, 3, 10
def pack(raw):
out = bytearray()
lit = bytearray()
last = {} # 3-Byte-Präfix -> Liste letzter Positionen
def flush():
for k in range(0, len(lit), 127):
part = lit[k:k + 127]
out.append(len(part))
out.extend(part)
lit.clear()
i, n = 0, len(raw)
while i < n:
best_len, best_off = 0, 0
if i + MINLEN <= n:
key = raw[i:i + 3]
for p in reversed(last.get(key, ())):
off = i - p
if off > MAXOFF:
break
ln = 3
while ln < MAXLEN and i + ln < n and raw[p + ln] == raw[i + ln]:
ln += 1
if ln > best_len:
best_len, best_off = ln, off
if ln == MAXLEN:
break
step = best_len if best_len >= MINLEN else 1
for j in range(i, min(i + step, n - 2)):
lst = last.setdefault(raw[j:j + 3], [])
lst.append(j)
if len(lst) > 16:
del lst[0]
if best_len >= MINLEN:
flush()
out.append(0x80 | ((best_len - 3) << 4) | (best_off >> 8))
out.append(best_off & 0xFF)
i += best_len
else:
lit.append(raw[i])
i += 1
flush()
return MAGIC + struct.pack('<III', 1, len(raw), len(out)) + bytes(out)
if __name__ == '__main__':
from PIL import Image
img = Image.open(sys.argv[1]).convert('RGB').resize((640, 480), Image.LANCZOS)
data = pack(img.tobytes())
open(sys.argv[2], 'wb').write(data)
print(f'{sys.argv[2]}: {len(data)} Bytes')
+72
View File
@@ -0,0 +1,72 @@
#!/usr/bin/env python3
"""STPC grob durchlaufen (nur Grenzen): Modelle, Animsets, Flag-0x100-Block, dann Strat-Code.
Nachgebaut aus FUN_0042ab50 / FUN_0041f770 / FUN_0041f8b0 (groove.exe)."""
import struct
def u32(d, o):
return struct.unpack_from('<I', d, o)[0]
def u16(d, o):
return struct.unpack_from('<H', d, o)[0]
def models(d, o, n):
hdr = o
o += n * 0x8c
out = []
for i in range(n):
h = hdr + i * 0x8c
start = o
o += u16(d, h + 0x86) * 2
o += u16(d, h + 0x6c) * 0x18
o += u16(d, h + 0x6e) * 0x1c
o += (u16(d, h + 0x78) + u16(d, h + 0x7a) + u16(d, h + 0x7c)) * 0x20
out.append((h, start, o))
return o, out
def animsets(d, o, n):
hdr = o
o += n * 0x20
for i in range(n):
h = hdr + i * 0x20
a0, a1, nfr, a3, a4, a5, a6, a7 = struct.unpack_from('<8I', d, h)
o += a0 * 4
if a3 == 0:
o += nfr * 8
if a4:
o += nfr * 4
o += nfr * 4
for f in range(nfr):
if a4:
if f == 0:
o += a4 * 8
else:
o += a4 * 2 + (2 if a4 & 1 else 0)
o += a6 * 0x40
return o
def walk(d, wfpc):
n = u32(d, 0)
o, mdl = 4, []
for _ in range(n): # FUN_0041f770(&p,1,..) je Modell: Header direkt gefolgt von Daten
o, mm = models(d, o, 1)
mdl += mm
na = u32(d, o); o += 4
for _ in range(na): # FUN_0041f8b0(&p,1,..) je Animset
o = animsets(d, o, 1)
extra = []
if wfpc & 0x100:
n3 = u32(d, o); o += 4
for _ in range(n3):
h = o
cnt = u32(d, h + 4)
o = h + 16
if cnt & 0x1fffffff:
o += cnt * 8
o, mm = models(d, o, cnt)
extra.append((h, cnt, mm))
return dict(models=mdl, nanim=na, extra=extra, code=o)
+35
View File
@@ -0,0 +1,35 @@
#!/usr/bin/env python3
"""Tabelle Strat-Typ x Level (Instanzzahl) über alle Level-WADs.
strat_table.py wads_dir > table.md"""
import glob
import os
import sys
from collections import defaultdict
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import strats # noqa: E402
wads = sorted(glob.glob(os.path.join(sys.argv[1], 't*.wad')))
tab = defaultdict(dict)
avail = defaultdict(set)
lv = []
for w in wads:
name = os.path.basename(w)[:-4].lower()
try:
ch, wf, m, lo, types = strats.level_types(w)
except KeyError:
continue
lv.append(name)
for nm, ss in types.items():
tab[nm][name] = len(ss)
for o, nm in strats.all_named(ch['STPC'], lo):
avail[nm].add(name)
short = [l[1:].replace('i0m', '/').replace('l', '').replace('m00', '/') for l in lv]
print('| Typ | Level (MAP-Instanzen) | Σ | im Code von n Levels |')
print('|---|---|---|---|')
for nm in sorted(tab, key=lambda k: (-len(tab[k]), k)):
cells = ' '.join(f'{l[1:]}:{tab[nm][l]}' for l in lv if l in tab[nm])
base = nm.replace('(spawn)', '')
print(f'| {nm} | {cells} | {sum(tab[nm].values())} | {len(avail.get(base, ()))} |')
print()
print('Nur im Code (nie direkt in MAP):', ', '.join(sorted(n for n in avail if n not in {k.replace("(spawn)", "") for k in tab})))
+79
View File
@@ -0,0 +1,79 @@
#!/usr/bin/env python3
"""Strat-Typen (Objekttypen) eines Level-WADs: Namen aus STPC-Strat-Code, MAP-Instanzen.
Strat-Code liegt am Ende von STPC (nach Modellen/Animsets). Bytecode = u32-Wörter
(low16 = Opcode, high16 = Kurzoperand). Ein Strat beginnt (fast immer) mit
... 0xd4, 0xea, <s32 rel> -> Name = C-String bei (Adresse nach rel) + rel
strats.py level.wad [--all]
"""
import os
import struct
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import map_dump # noqa: E402
import stpcwalk # noqa: E402
def cstr(d, o):
e = d.index(b'\0', o)
return d[o:e].decode('latin1')
def strat_name(st, entry, lo):
"""Name eines MAP-Einstiegs: Spawn-Stub (b2 <abs> e0 ...) -> Ziel; sonst erstes d4/ea ab entry."""
w = struct.unpack_from('<8I', st, entry)
for k in range(5):
if w[k] == 0xb2 and w[k + 2] == 0xe0 and lo <= w[k + 1] < len(st):
n = strat_name(st, w[k + 1], lo)
return n + '(spawn)' if n else None
for k in range(0, 1024, 4):
o = entry + k
if o + 12 > len(st):
break
a, b = struct.unpack_from('<II', st, o)
if a == 0xd4 and b == 0xea:
rel = struct.unpack_from('<i', st, o + 8)[0]
n = o + 12 + rel
if lo <= n < len(st):
return cstr(st, n)
return None
def all_named(st, lo):
"""Alle d4/ea-Namensstellen im Code-Bereich (Wort-Raster ab lo)."""
out = []
for o in range(lo, len(st) - 12, 4):
a, b = struct.unpack_from('<II', st, o)
if a == 0xd4 and b == 0xea:
rel = struct.unpack_from('<i', st, o + 8)[0]
n = o + 12 + rel
if lo <= n < o:
out.append((o, cstr(st, n)))
return out
def level_types(path):
ch, wf, m = map_dump.load(path)
st = ch['STPC']
lo = stpcwalk.walk(st, wf)['code']
types = {}
for s in m['strats']:
code = s[6]
nm = strat_name(st, code, lo) or f'?@{code:#x}'
types.setdefault(nm, []).append(s)
return ch, wf, m, lo, types
if __name__ == '__main__':
ch, wf, m, lo, types = level_types(sys.argv[1])
print(f'code region {lo:#x}..{len(ch["STPC"]):#x}')
for nm, ss in sorted(types.items()):
s = ss[0]
print(f'{len(ss):3d} x {nm:32s} code@{s[6]:#x} nparams={s[7]} nvars={s[9]} f30={s[10]} '
f'flags={s[11]:#x} extra={s[12]:#x}')
if '--all' in sys.argv:
used = {s[6] for v in types.values() for s in v}
print('-- alle benannten Strats im Code (Namens-Op-Adresse):')
for o, n in all_named(ch['STPC'], lo):
print(f' {o:#x} {n}')
+89
View File
@@ -0,0 +1,89 @@
#!/usr/bin/env python3
"""Textur-Kontaktabzug: alle (oder nur die von der Welt-Geometrie benutzten) Texturen eines
Level-WADs als Raster mit Index-Beschriftung -> zum Aussuchen von Texturen fuer levelbuild.py.
texsheet.py level.wad out.png [--all] [--size 64] [--cols 16] [--only 12,40,77]
Ohne --all: nur Texturen, die in TRAK (Welt-Kacheln) vorkommen, sortiert nach Haeufigkeit.
Beschriftung: Index, darunter Nutzung O=oben (Boden) / S=Seite (Wand) / U=unten, Groesse in px.
Flachfarben (Flag Bit0) werden als Farbfeld gezeigt ("F").
"""
import argparse
import collections
import os
import sys
from PIL import Image, ImageDraw
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import geomlib as G # noqa: E402
def tex_image(pages_rgba, t, size):
if t['flags'] & 1 or t['page'] < 0:
im = Image.new('RGBA', (size, size), (t['u0'], t['u1'], t['v0'], 255))
return im
pg = pages_rgba[t['page']]
crop = pg.crop((t['u0'], t['v0'], t['u1'] + 1, t['v1'] + 1))
return crop.resize((size, size), Image.NEAREST)
def usage(trak):
up, side, down = collections.Counter(), collections.Counter(), collections.Counter()
for m in trak:
for f in m.faces:
ny = f['plane'][1]
(up if ny > 0.7 else down if ny < -0.7 else side)[f['tex']] += 1
return up, side, down
def main():
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument('wad'); ap.add_argument('out')
ap.add_argument('--all', action='store_true')
ap.add_argument('--size', type=int, default=64)
ap.add_argument('--cols', type=int, default=16)
ap.add_argument('--only', help='kommagetrennte Indizes')
a = ap.parse_args()
ch = G.wad_read(a.wad)
pages, texs = G.parse_text(G.wad_get(ch, 'TEXT'))
prgba = [Image.fromarray(G.page_rgba(p), 'RGBA') for p in pages]
up, side, down = usage(G.parse_trak(G.wad_get(ch, 'TRAK')))
tot = up + side + down
if a.only:
idx = [int(x) for x in a.only.split(',')]
elif a.all:
idx = list(range(len(texs)))
else:
idx = [i for i, _ in tot.most_common()]
S, C = a.size, a.cols
cw, chh = S + 8, S + 30
rows = (len(idx) + C - 1) // C
sheet = Image.new('RGB', (C * cw, rows * chh), (40, 40, 48))
d = ImageDraw.Draw(sheet)
for k, i in enumerate(idx):
t = texs[i]
x, y = (k % C) * cw + 4, (k // C) * chh + 2
# Schachbrett fuer Transparenz
bg = Image.new('RGB', (S, S), (90, 90, 90))
bd = ImageDraw.Draw(bg)
for yy in range(0, S, 8):
for xx in range(0, S, 8):
if (xx + yy) // 8 % 2:
bd.rectangle((xx, yy, xx + 7, yy + 7), fill=(130, 130, 130))
im = tex_image(prgba, t, S)
bg.paste(im, (0, 0), im)
sheet.paste(bg, (x, y))
w, h = t['u1'] - t['u0'] + 1, t['v1'] - t['v0'] + 1
tag = 'F' if t['flags'] & 1 else ''
if up[i] or side[i] or down[i]:
tag += 'O' if up[i] >= max(side[i], down[i]) else ('S' if side[i] >= down[i] else 'U')
d.text((x, y + S + 1), f'{i}', fill=(255, 255, 120))
d.text((x + S // 2 + 4, y + S + 1), tag, fill=(140, 220, 255))
d.text((x, y + S + 14), f'{w}x{h}', fill=(170, 170, 170))
sheet.save(a.out)
print(f'{a.out}: {len(idx)} Texturen, {sheet.size[0]}x{sheet.size[1]}')
if __name__ == '__main__':
main()
+54
View File
@@ -0,0 +1,54 @@
#!/usr/bin/env python3
"""TEXT-Chunk -> PNG-Seiten (+ texinfo.txt) und zurück.
text_export.py export level.wad outdir # pageNN_fXX.png, texinfo.txt
text_export.py import level.wad outdir neu.wad # geänderte PNGs zurückschreiben
"""
import glob
import os
import re
import sys
import numpy as np
from PIL import Image
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import textchunk as T # noqa: E402
import wadtool # noqa: E402
def main():
cmd, wad, out = sys.argv[1:4]
ch = wadtool.read(wad)
tc = wadtool.get(ch, 'TEXT')
t = T.parse(tc[1])
if cmd == 'export':
os.makedirs(out, exist_ok=True)
for i, p in enumerate(t['pages']):
if p['flags'] == 0:
Image.fromarray(p['px']).save(f'{out}/page{i:02d}_f{p["flags"]:02x}.png')
else:
Image.fromarray(T.abgr1555_to_rgba(p['px'])).save(f'{out}/page{i:02d}_f{p["flags"]:02x}.png')
with open(f'{out}/texinfo.txt', 'w') as f:
f.write('# idx flags page clut x0 x1 y0 y1\n')
for i, e in enumerate(T.tex_entries(t['tail'])):
f.write(f'{i} {e[0]:#06x} {e[1]} {e[2]:#04x} {e[3]} {e[4]} {e[5]} {e[6]}\n')
print(f'{len(t["pages"])} Seiten, {len(T.tex_entries(t["tail"]))} Texturen -> {out}')
elif cmd == 'import':
for fn in glob.glob(f'{out}/page*.png'):
i = int(re.search(r'page(\d+)', fn).group(1))
p = t['pages'][i]
img = np.asarray(Image.open(fn).convert('RGBA'))
assert img.shape[:2] == (p['h'], p['w']), fn
new = T.rgba_to_abgr1555(img)
# unveränderte Pixel exakt erhalten (Rundung 8->5 Bit verlustfrei rückwärts)
old = T.abgr1555_to_rgba(p['px'])
same = (old == img).all(-1)
p['px'] = np.where(same, p['px'], new).astype(np.uint16)
tc[1] = T.build(t)
wadtool.write(sys.argv[4], ch)
print('geschrieben:', sys.argv[4])
if __name__ == '__main__':
main()
+117
View File
@@ -0,0 +1,117 @@
#!/usr/bin/env python3
"""TEXT-Chunk (Texturen) von Level-WADs lesen/schreiben.
Aufbau (aus groove.exe 0x4067b0):
u32 nPal; nPal x { u32 id, u32 n, u16 col[n], u8 extra[n] }
u32 nPages; nPages x { u32 flags, u32 w, u32 h, Daten }
flags==0 : w*h Byte (8-Bit, Palette)
flags&0x80 : u32 Länge, RLE16 (s16 n: n>0 -> n Literal-u16, n<0 -> nächstes u16 (-n)-mal)
sonst : w*h u16 roh
u32 nTex; Rest = Textur-Einträge (8 Byte) + Anhang (siehe notes/textures.md)
"""
import struct
import numpy as np
def rle_decode(b, npix):
out = np.zeros(npix, np.uint16)
v = np.frombuffer(b, '<u2')
i = p = 0
while p < npix and i < len(v):
n = int(v[i]) - 0x10000 if v[i] & 0x8000 else int(v[i]); i += 1
if n > 0:
out[p:p + n] = v[i:i + n]; i += n; p += n
elif n < 0:
out[p:p - n] = v[i]; i += 1; p -= n
return out
def rle_encode(px):
px = np.asarray(px, np.uint16)
out, i, N = [], 0, len(px)
while i < N:
j = i
while j + 1 < N and px[j + 1] == px[i] and j - i < 32766:
j += 1
if j > i: # Lauf >= 2
out += [(-(j - i + 1)) & 0xffff, int(px[i])]; i = j + 1
continue
k = i # Literale bis zum nächsten Lauf
while k < N and k - i < 32767 and not (k + 1 < N and px[k + 1] == px[k]):
k += 1
out += [k - i] + [int(x) for x in px[i:k]]; i = k
return struct.pack('<%dH' % len(out), *out)
def parse(d):
o = 0
pals = []
n = struct.unpack_from('<I', d, o)[0]; o += 4
for _ in range(n):
pid, c = struct.unpack_from('<II', d, o); o += 8
pals.append((pid, d[o:o + 2 * c], d[o + 2 * c:o + 3 * c])); o += 3 * c
pages = []
n = struct.unpack_from('<I', d, o)[0]; o += 4
for _ in range(n):
f, w, h = struct.unpack_from('<3I', d, o); o += 12
if f == 0:
raw = d[o:o + w * h]; o += w * h
px = np.frombuffer(raw, np.uint8)
elif f & 0x80:
s = struct.unpack_from('<I', d, o)[0]; o += 4
px = rle_decode(d[o:o + s], w * h); o += s
else:
px = np.frombuffer(d[o:o + 2 * w * h], '<u2').copy(); o += 2 * w * h
pages.append(dict(flags=f, w=w, h=h, px=px.reshape(h, w)))
return dict(pals=pals, pages=pages, tail=d[o:])
def build(t, compress=True):
out = [struct.pack('<I', len(t['pals']))]
for pid, col, ex in t['pals']:
out.append(struct.pack('<II', pid, len(ex)) + col + ex)
out.append(struct.pack('<I', len(t['pages'])))
for p in t['pages']:
f, w, h = p['flags'], p['w'], p['h']
if f == 0:
out.append(struct.pack('<3I', f, w, h) + p['px'].astype(np.uint8).tobytes())
elif f & 0x80 and compress:
b = rle_encode(p['px'].reshape(-1))
out.append(struct.pack('<4I', f, w, h, len(b)) + b)
else:
out.append(struct.pack('<3I', f & ~0x80, w, h) + p['px'].astype('<u2').tobytes())
out.append(t['tail'])
return b''.join(out)
def abgr1555_to_rgba(px):
"""PS1-Format: Bits 0-4 R, 5-9 G, 10-14 B, Bit 15 = STP.
0x0000 = durchsichtig (Alpha 0). STP=1 -> Alpha 255, STP=0 (Pixel != 0) -> Alpha 128
(auf Seiten mit Blendmodus = halbtransparent)."""
px = px.astype(np.uint32)
r = (px & 31) * 255 // 31
g = ((px >> 5) & 31) * 255 // 31
b = ((px >> 10) & 31) * 255 // 31
a = np.where(px == 0, 0, np.where(px & 0x8000, 255, 128))
return np.dstack([r, g, b, a]).astype(np.uint8)
def rgba_to_abgr1555(img):
"""Umkehrung: Alpha < 64 -> 0x0000, 64..191 -> STP=0, >= 192 (oder RGB-Bild) -> STP=1."""
img = np.asarray(img).astype(np.uint32)
v = ((img[..., 2] >> 3) << 10) | ((img[..., 1] >> 3) << 5) | (img[..., 0] >> 3)
if img.shape[-1] == 4:
a = img[..., 3]
v = np.where(a >= 192, v | 0x8000, v)
v = np.where((a >= 64) & (a < 192) & (v == 0), 0x0421, v) # fast schwarz statt durchsichtig
v = np.where(a < 64, 0, v)
else:
v = v | 0x8000
return v.astype(np.uint16)
def tex_entries(tail):
"""Textur-Einträge (Faces referenzieren diese Indizes): u16 flags, u8 page, u8 clut(0xff),
u8 x0, u8 x1, u8 y0, u8 y1 (inklusive Kanten)."""
n = struct.unpack_from('<I', tail, 0)[0]
return [struct.unpack_from('<H6B', tail, 4 + 8 * i) for i in range(n)]
+28
View File
@@ -0,0 +1,28 @@
# label.py IN.png LABELS.json OUT.jpg "Titel" [breite] -> beschriftete JPG-Vorschau
import sys, json
from PIL import Image, ImageDraw, ImageFont
src, lj, out, title = sys.argv[1:5]
W = int(sys.argv[5]) if len(sys.argv) > 5 else 1280
im = Image.open(src).convert('RGB')
im = im.resize((W, round(im.height * W / im.width)), Image.LANCZOS)
d = ImageDraw.Draw(im, 'RGBA')
F = '/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf'
f = ImageFont.truetype(F, 30); ft = ImageFont.truetype(F, 26)
COL = {'Start': (255, 120, 40), 'Galerie': (255, 205, 40), 'Gehege': (120, 220, 90),
'Parkour': (80, 170, 255), 'Ziel': (200, 110, 255)}
OFF = {'Galerie': (0, 0), 'Parkour': (0, -40), 'Ziel': (0, -30), 'Start': (0, -40), 'Gehege': (0, 0)}
lab = json.load(open(lj))
for k, (u, v) in lab.items():
if not (0 <= u <= 1 and 0 <= v <= 1):
continue
x, y = u * im.width + OFF[k][0], v * im.height + OFF[k][1]
tw, th = d.textbbox((0, 0), k, font=f)[2:]
bx = (x - tw / 2 - 12, y - th / 2 - 8, x + tw / 2 + 12, y + th / 2 + 10)
d.rounded_rectangle(bx, 10, fill=(20, 20, 30, 190), outline=COL[k] + (255,), width=3)
d.text((x - tw / 2, y - th / 2), k, font=f, fill=COL[k])
if title:
tw, th = d.textbbox((0, 0), title, font=ft)[2:]
d.rectangle((0, 0, tw + 30, th + 20), fill=(20, 20, 30, 170))
d.text((15, 8), title, font=ft, fill=(255, 255, 255))
im.save(out, quality=85, optimize=True)
print(out, im.size)
+203
View File
@@ -0,0 +1,203 @@
# Blender 3.0 (headless): level.obj aus geom_export (v x y z r g b, vt, usemtl page_NN/flat_N) laden,
# in Spielkoordinaten (Blender x = Spiel-x, y = -Spiel-z, z = Spiel-y) rendern.
# blender -b -P render_level.py -- OBJDIR OUTPREFIX [samples] [scale%]
import bpy, sys, os, json, math
from mathutils import Vector
from bpy_extras.object_utils import world_to_camera_view
argv = sys.argv[sys.argv.index('--') + 1:]
objdir, outp = argv[0], argv[1]
samples = int(argv[2]) if len(argv) > 2 else 64
scale = int(argv[3]) if len(argv) > 3 else 100
bpy.ops.wm.read_factory_settings(use_empty=True)
sc = bpy.context.scene
# --------------------------------------------------------------- MTL
mats = {}
cur = None
for ln in open(os.path.join(objdir, 'level.mtl')):
p = ln.split()
if not p:
continue
if p[0] == 'newmtl':
cur = p[1]; mats[cur] = {}
elif p[0] == 'Kd':
mats[cur]['kd'] = tuple(float(v) for v in p[1:4])
elif p[0] == 'map_Kd':
mats[cur]['img'] = p[1]
def make_mat(name, d):
m = bpy.data.materials.new(name)
m.use_nodes = True
nt = m.node_tree; N = nt.nodes; Lk = nt.links
N.clear()
out = N.new('ShaderNodeOutputMaterial')
bsdf = N.new('ShaderNodeBsdfPrincipled')
bsdf.inputs['Roughness'].default_value = 0.9
bsdf.inputs['Specular'].default_value = 0.15
vc = N.new('ShaderNodeVertexColor'); vc.layer_name = 'Col'
mul = N.new('ShaderNodeMixRGB'); mul.blend_type = 'MULTIPLY'; mul.inputs[0].default_value = 1.0
Lk.new(vc.outputs['Color'], mul.inputs[2])
if 'img' in d:
tex = N.new('ShaderNodeTexImage')
tex.image = bpy.data.images.load(os.path.join(objdir, d['img']))
tex.interpolation = 'Closest'
Lk.new(tex.outputs['Color'], mul.inputs[1])
Lk.new(tex.outputs['Alpha'], bsdf.inputs['Alpha'])
m.blend_method = 'CLIP'
else:
kd = d.get('kd', (1, 1, 1))
mul.inputs[1].default_value = (*kd, 1)
if kd[2] > 0.5 and kd[0] < 0.3: # Meer
bsdf.inputs['Roughness'].default_value = 0.25
bsdf.inputs['Specular'].default_value = 0.5
# leicht selbstleuchtend, damit die eingebackene Beleuchtung (Vertexfarben) sichtbar bleibt
Lk.new(mul.outputs[0], bsdf.inputs['Base Color'])
Lk.new(mul.outputs[0], bsdf.inputs['Emission'])
bsdf.inputs['Emission Strength'].default_value = 0.25
Lk.new(bsdf.outputs[0], out.inputs[0])
return m
# --------------------------------------------------------------- OBJ
V, C, VT = [], [], []
faces = [] # (vidx, vtidx, mat)
mat = None
for ln in open(os.path.join(objdir, 'level.obj')):
if ln.startswith('v '):
p = ln.split()
x, y, z = float(p[1]), float(p[2]), float(p[3])
V.append((x, z, y)) # Spiel (x, y, -z) -> Blender (x, -spiel_z, y) = (x, z_obj, y_obj)
C.append((float(p[4]), float(p[5]), float(p[6])) if len(p) >= 7 else (1, 1, 1))
elif ln.startswith('vt '):
p = ln.split(); VT.append((float(p[1]), float(p[2])))
elif ln.startswith('usemtl'):
mat = ln.split()[1]
elif ln.startswith('f '):
vi, ti = [], []
for t in ln.split()[1:]:
q = t.split('/')
vi.append(int(q[0]) - 1)
ti.append(int(q[1]) - 1 if len(q) > 1 and q[1] else -1)
faces.append((vi[::-1], ti[::-1], mat)) # Spiegelung -> Umlaufsinn umdrehen
matnames = sorted(set(f[2] for f in faces))
mesh = bpy.data.meshes.new('level')
mesh.from_pydata(V, [], [f[0] for f in faces])
for n in matnames:
mesh.materials.append(make_mat(n, mats.get(n, {})))
mi = {n: i for i, n in enumerate(matnames)}
uvl = mesh.uv_layers.new(name='UV')
col = mesh.vertex_colors.new(name='Col')
for poly, (vi, ti, m) in zip(mesh.polygons, faces):
poly.material_index = mi[m]
for k, li in enumerate(poly.loop_indices):
t = ti[k]
uvl.data[li].uv = VT[t] if t >= 0 else (0, 0)
c = C[vi[k]]
col.data[li].color = (c[0], c[1], c[2], 1)
mesh.update()
ob = bpy.data.objects.new('level', mesh)
sc.collection.objects.link(ob)
# --------------------------------------------------------------- Objekt-Marker (Strats aus strats.json)
def flatmat(name, rgb, emit=0.6):
m = bpy.data.materials.new(name); m.use_nodes = True
b = m.node_tree.nodes['Principled BSDF']
b.inputs['Base Color'].default_value = (*rgb, 1)
b.inputs['Emission'].default_value = (*rgb, 1); b.inputs['Emission Strength'].default_value = emit
b.inputs['Roughness'].default_value = 0.4
return m
sj = os.environ.get('STRATS')
if sj and os.path.exists(sj):
ROWZ = [22.5, 25.2, 27.9, 30.6]
rowcol = [(1.0, 0.8, 0.1), (0.75, 0.75, 0.78), (0.2, 0.45, 1.0), (0.95, 0.15, 0.12)]
M = {k: flatmat(k, c) for k, c in [('coin', (1.0, 0.75, 0.05)), ('lama', (1.0, 0.3, 0.05)),
('cp', (1.0, 0.55, 0.0)), ('tuer', (0.75, 0.2, 1.0)),
('tier', (0.95, 0.95, 0.9))]}
RM = [flatmat('row%d' % i, c) for i, c in enumerate(rowcol)]
for d in json.load(open(sj)):
n, x, y, z = d['name'], d['x'], d['y'], d['z']
loc = (x, -z, y)
if n == 'Coin' and not any(abs(z - r) < 0.3 for r in ROWZ):
bpy.ops.mesh.primitive_cylinder_add(radius=0.18, depth=0.05, location=(x, -z, y + 0.35),
rotation=(math.radians(90), 0, math.radians(90)))
mat = M['coin']
elif n == 'Llama':
bpy.ops.mesh.primitive_cone_add(radius1=0.3, depth=0.9, location=(x, -z, y + 0.45)); mat = M['lama']
elif n == 'CheckPointPost' and not any(abs(z - r) < 0.3 for r in ROWZ):
bpy.ops.mesh.primitive_cylinder_add(radius=0.1, depth=1.2, location=(x, -z, y + 0.6)); mat = M['cp']
elif n == 'LevelDoor':
bpy.ops.mesh.primitive_torus_add(major_radius=0.4, minor_radius=0.07, location=(x, -z, y + 0.6),
rotation=(0, math.radians(90), 0)); mat = M['tuer']
elif n in ('Bunny', 'Ladybird', 'FlockOfBirds', 'VillagerHerder'):
bpy.ops.mesh.primitive_uv_sphere_add(radius=0.25, location=(x, -z, y + 0.3)); mat = M['tier']
else:
r = min(range(4), key=lambda i: abs(z - ROWZ[i]))
bpy.ops.mesh.primitive_ico_sphere_add(radius=0.22, subdivisions=1, location=(x, -z, y + 0.4))
mat = RM[r]
o = bpy.context.active_object; o.data.materials.append(mat)
# --------------------------------------------------------------- Licht, Welt
w = bpy.data.worlds.new('w'); sc.world = w; w.use_nodes = True
bg = w.node_tree.nodes['Background']
bg.inputs[0].default_value = (0.55, 0.72, 0.95, 1); bg.inputs[1].default_value = 0.9
sun = bpy.data.lights.new('sun', 'SUN'); sun.energy = 2.6; sun.angle = math.radians(6)
so = bpy.data.objects.new('sun', sun); sc.collection.objects.link(so)
so.rotation_euler = (math.radians(42), 0, math.radians(-140))
# Cycles liefert mit dem Debian-Blender 3.0.1 nur Schwarz -> Eevee (braucht X: eigenes Xvfb, z. B. :7)
sc.render.engine = 'BLENDER_EEVEE'
ee = sc.eevee
ee.taa_render_samples = samples
ee.use_gtao = True; ee.gtao_distance = 1.5
ee.use_soft_shadows = True
ee.shadow_cascade_size = '4096'
sun.shadow_cascade_max_distance = 120
sun.shadow_cascade_count = 4
try:
sc.view_settings.view_transform = 'Filmic'
sc.view_settings.look = 'Medium High Contrast'
except TypeError:
pass # Debian-Blender ohne OCIO-Konfiguration: nur 'Standard'
# Bereiche (Spielkoordinaten x, z, y) fuer die Beschriftung
LABELS = {'Start': (18, 15.5, 2.2), 'Galerie': (23.5, 26.5, 1.2), 'Gehege': (37.5, 25.4, 1.2),
'Parkour': (33.0, 15.5, 2.6), 'Ziel': (50.0, 15.5, 6.0)}
def shoot(name, loc, look, lens=35, ortho=None, res=(1600, 1000)):
cam = bpy.data.cameras.new(name)
co = bpy.data.objects.new(name, cam); sc.collection.objects.link(co)
co.location = loc
d = Vector(look) - Vector(loc)
co.rotation_euler = d.to_track_quat('-Z', 'Y').to_euler()
if ortho:
cam.type = 'ORTHO'; cam.ortho_scale = ortho
else:
cam.lens = lens
cam.clip_end = 500
sc.camera = co
sc.render.resolution_x, sc.render.resolution_y = res
sc.render.resolution_percentage = scale
sc.render.filepath = outp + '_' + name + '.png'
bpy.ops.render.render(write_still=True)
lab = {}
for k, (x, z, y) in LABELS.items():
v = world_to_camera_view(sc, co, Vector((x, -z, y)))
lab[k] = (v.x, 1 - v.y)
json.dump(lab, open(outp + '_' + name + '.json', 'w'))
views = os.environ.get('VIEWS', 'schraeg,oben').split(',')
if 'schraeg' in views: # von Suedwesten, schraeg oben
shoot('schraeg', (10, -50, 27), (30.5, -21.5, 0), lens=33)
if 'schraeg2' in views: # von Nordosten (hinter dem Ziel)
shoot('schraeg2', (60, 2, 22), (29, -21, 0), lens=30)
if 'oben' in views:
shoot('oben', (31, -21.5, 80), (31, -21.4, 0), ortho=44)
+9
View File
@@ -0,0 +1,9 @@
# Strat-Positionen eines Levelskripts als JSON (fuer Marker im Blender-Render)
import sys, json, os
sys.path.insert(0, 'tools')
import levelbuild as LB
mod = LB.load_script(sys.argv[1])
L = LB.Level(LB.BASE, **getattr(mod, 'LEVEL_ARGS', {})); L.opts = {}
mod.build(L)
json.dump([dict(name=d['name'], x=d['x'], y=d['y'], z=d['z']) for d in L.strats], open(sys.argv[2], 'w'))
print(len(L.strats))
+64
View File
@@ -0,0 +1,64 @@
#!/usr/bin/env python3
"""Liest und schreibt Level-WADs von "Ein Königreich für ein Lama" (Argonaut).
Aufbau: u32 Gesamtlänge (ohne dieses Feld), dann Chunks:
4 Byte Kennung (rückwärts gespeichert, z. B. 'OFNI' = INFO), u32 Länge, Daten
bis zum Chunk 'END '.
wadtool.py list level.wad
wadtool.py name level.wad neu.wad "Neuer Levelname"
wadtool.py extract level.wad CHUNK datei.bin
wadtool.py replace level.wad neu.wad CHUNK datei.bin
"""
import struct
import sys
def read(path):
d = open(path, 'rb').read()
total = struct.unpack_from('<I', d, 0)[0]
if total != len(d) - 4:
raise ValueError(f'{path}: Längenfeld {total} passt nicht zu {len(d) - 4}')
chunks, o = [], 4
while o + 8 <= len(d):
tag = d[o:o + 4][::-1].decode('latin1')
ln = struct.unpack_from('<I', d, o + 4)[0]
chunks.append([tag, d[o + 8:o + 8 + ln]])
o += 8 + ln
if tag == 'END ':
break
return chunks
def write(path, chunks):
body = b''.join(t.encode('latin1')[::-1] + struct.pack('<I', len(c)) + c for t, c in chunks)
open(path, 'wb').write(struct.pack('<I', len(body)) + body)
def get(chunks, tag):
for c in chunks:
if c[0] == tag:
return c
raise KeyError(tag)
if __name__ == '__main__':
cmd, src = sys.argv[1], sys.argv[2]
ch = read(src)
if cmd == 'list':
for t, c in ch:
extra = ''
if t == 'NAME':
extra = repr(c[4:].split(b'\0')[0].decode('latin1'))
print(f'{t!r:8} {len(c):10d} {extra}')
elif cmd == 'name':
c = get(ch, 'NAME')
c[1] = c[1][:4] + sys.argv[4].encode('latin1') + b'\0'
write(sys.argv[3], ch)
elif cmd == 'extract':
open(sys.argv[4], 'wb').write(get(ch, sys.argv[3].ljust(4))[1])
elif cmd == 'replace':
get(ch, sys.argv[4].ljust(4))[1] = open(sys.argv[5], 'rb').read()
write(sys.argv[3], ch)
else:
sys.exit(__doc__)