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:
commit
b36a366240
826 files changed
+105304
No files matched your search
Executable
+720
@@ -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:]))
|
||||
@@ -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())
|
||||
@@ -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])
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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}')
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
@@ -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
|
||||
Executable
+944
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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')
|
||||
@@ -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)
|
||||
@@ -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})))
|
||||
@@ -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}')
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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)]
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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))
|
||||
@@ -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__)
|
||||
Reference in new issue
Block a user