#!/usr/bin/env python3 """modkit: the mod-author CLI (20-developer-tooling.md, D12). python3 tools/modkit.py [args] Subcommands: scaffold [--profile content|overhaul|total_conversion] [--api 2] [--github owner/repo] [--experimental] [--dest DIR] [--force] translation [--language NAME] [--base auto|fixture|imported] [--refresh] [--dest DIR] validate [--strict] [--base auto|fixture|imported] lint pack [-o out.modpkg] bounce [--seconds N] [--out DIR] docs [--out DIR] set-github add/update manifest "github" (auto-update) add-release-workflow copy GitHub Actions release.yml into the mod Global flags: --repo PATH, --json, --quiet. Exit codes: 0 success, 1 validation/lint failure, 2 usage error. validate drives the real engine loader headlessly (luajit, injected fs) so a mod that passes here will not surface load errors in-game. --base auto folds over the player's imported dataset when there is one and falls back to the ROM-free fixture in tests/fixture_data/ otherwise, which is what keeps the tool runnable on a CI box with no ROM. Which base ran matters to MK103: only the imported dataset owns the real vanilla id space, so over the fixture that rule is reported as skipped rather than guessed at. lint is the no-ROM-content distribution gate (MK3xx); pack runs both at --strict, so any finding -- warning included -- refuses the package. """ import argparse import hashlib import io import json import os import re import subprocess import sys import unicodedata import tempfile import zipfile from datetime import datetime, timezone MODKIT_VERSION = "1.0.0" LUAJIT = os.environ.get("MODKIT_LUAJIT", "luajit") IMAGE_EXTS = {".png"} ASSET_EXTS = {".png", ".wav", ".bin"} ROM_PATCH_EXTS = {".gb", ".gbc", ".ips", ".bps"} SKIP_DIRS = {".git", ".modkit", "__pycache__", ".vscode"} GENERATED_MODULES = [ "constants", "maps", "tilesets", "text", "text_pointers", "trainer_headers", "font", "sprites", "pokemon", "moves", "items", "type_chart", "trainers", "encounters", "field", "battle_anims", "audio", "palettes", "icons", ] # ---------------------------------------------------------------- findings class Finding: def __init__(self, rule, severity, message, path=None): self.rule = rule self.severity = severity # "error" | "warn" self.message = message self.path = path def as_dict(self): return {"rule": self.rule, "severity": self.severity, "message": self.message, "path": self.path} def line(self): where = f"{self.path}: " if self.path else "" return f"{self.rule} {self.severity.upper():5} {where}{self.message}" def report(findings, args, summary_ok, summary_fail, notes=None): """notes are rules that could not run, not findings against the mod, so --strict never promotes them and they never change the exit code.""" notes = notes or [] errors = [f for f in findings if f.severity == "error"] warns = [f for f in findings if f.severity == "warn"] if getattr(args, "strict", False): errors, warns = errors + warns, [] if args.json: print(json.dumps({"ok": not errors, "findings": [f.as_dict() for f in findings], "notes": notes})) else: for f in findings: print(f.line()) if not args.quiet: for note in notes: print(f"modkit: {note}") print(summary_fail if errors else summary_ok) return 1 if errors else 0 # ---------------------------------------------------------------- repo/root def find_repo(start): node = os.path.abspath(start) while True: if os.path.isfile(os.path.join(node, "tools", "rom_manifest.json")): return node parent = os.path.dirname(node) if parent == node: return None node = parent def engine_version(repo): src = open(os.path.join(repo, "src", "core", "Version.lua"), encoding="utf-8").read() match = re.search(r'engine\s*=\s*"([^"]+)"', src) return match.group(1) if match else "0.0.0-dev" def known_permissions(repo): """The vocabulary the engine itself enforces (Manifest.PERMISSIONS), read from the source so a lint rule can never disagree with the loader.""" try: src = open(os.path.join(repo, "src", "mods", "Manifest.lua"), encoding="utf-8").read() except OSError: return {"network", "filesystem", "engine_internals"} block = re.search(r"Manifest\.PERMISSIONS\s*=\s*\{([^}]*)\}", src) names = set(re.findall(r"(\w+)\s*=\s*true", block.group(1))) \ if block else set() return names or {"network", "filesystem", "engine_internals"} def supported_requires(repo): """The src.* modules the mod surface points authors at; requiring one of these is not reaching past the API (Loader.lua SUPPORTED_REQUIRES).""" try: src = open(os.path.join(repo, "src", "mods", "Loader.lua"), encoding="utf-8").read() except OSError: return {"src.mods.Semver", "src.audio.ChipAsm"} block = re.search(r"SUPPORTED_REQUIRES\s*=\s*\{(.*?)\}", src, re.S) names = set(re.findall(r'\["([^"]+)"\]', block.group(1))) \ if block else set() return names or {"src.mods.Semver", "src.audio.ChipAsm"} def resolve_mod_dir(repo, arg): if os.path.isdir(arg): return os.path.abspath(arg) candidate = os.path.join(repo, "mods", arg) if os.path.isdir(candidate): return candidate return None def mod_files(mod_dir): """Sorted relative paths of everything a package would carry.""" ignored = set() ignore_file = os.path.join(mod_dir, ".modkitignore") if os.path.isfile(ignore_file): for line in open(ignore_file, encoding="utf-8"): line = line.strip() if line and not line.startswith("#"): ignored.add(line) out = [] for base, dirs, files in os.walk(mod_dir): dirs[:] = [d for d in dirs if d not in SKIP_DIRS and not d.startswith(".")] for name in files: if name.startswith(".") and name != ".luarc.json": continue rel = os.path.relpath(os.path.join(base, name), mod_dir) rel = rel.replace(os.sep, "/") if rel in ignored or rel == ".modkitignore": continue out.append(rel) return sorted(out) def read_manifest(mod_dir): path = os.path.join(mod_dir, "manifest.json") if not os.path.isfile(path): return None, Finding("MK001", "error", "manifest.json missing", "manifest.json") try: manifest = json.load(open(path, encoding="utf-8")) except ValueError as err: return None, Finding("MK001", "error", f"manifest.json unparseable: {err}", "manifest.json") mod_id = manifest.get("id") if not isinstance(mod_id, str) or not re.fullmatch(r"[\w\-]+", mod_id): return None, Finding("MK001", "error", "manifest id must match ^[%w_-]+$", "manifest.json") return manifest, None # ------------------------------------------------- permissions (MK005/MK006) def check_permissions(repo, manifest): """MK005: every declared permission is from the engine's known set. The loader turns this into a hard load failure for api 2 and a warning for api 1, so naming it here is what makes the finding readable either way.""" findings = [] declared = manifest.get("permissions", []) if declared is None: return findings if not isinstance(declared, list): return [Finding("MK005", "error", "permissions must be an array of strings", "manifest.json")] known = known_permissions(repo) for name in declared: if not isinstance(name, str) or name not in known: findings.append(Finding( "MK005", "error", f"unknown permission {name!r}; the known set is " + ", ".join(sorted(known)), "manifest.json")) return findings def strip_lua(body): """Blanks comments so a commented-out example never trips a scan, keeping line numbers intact. A string literal is stepped over rather than blanked -- the module name a require scan is after IS a string -- so a `--` inside a path is not read as a comment; the keyword itself is masked inside the literal so prose quoting a require call cannot look like one.""" out, index, size = [], 0, len(body) long_open = re.compile(r"\[(=*)\[") def literal(text): return text.replace("require", " " * len("require")) while index < size: char = body[index] if char in "\"'": quote = char start = index index += 1 while index < size: if body[index] == "\\" and index + 1 < size: index += 2 continue index += 1 if body[index - 1] == quote: break out.append(literal(body[start:index])) continue comment = body.startswith("--", index) opener = long_open.match(body, index + 2 if comment else index) if comment: if opener: close = "]" + opener.group(1) + "]" end = body.find(close, opener.end()) chunk = (body[index:] if end < 0 else body[index:end + len(close)]) else: end = body.find("\n", index) chunk = body[index:] if end < 0 else body[index:end] out.append("\n" * chunk.count("\n")) index += len(chunk) continue if opener and opener.start() == index: close = "]" + opener.group(1) + "]" end = body.find(close, opener.end()) chunk = body[index:] if end < 0 else body[index:end + len(close)] out.append(literal(chunk)) index += len(chunk) continue out.append(char) index += 1 return "".join(out) REQUIRE_CALL = re.compile(r"""\brequire\s*\(?\s*["']([^"']+)["']""") def check_requires(repo, mod_dir, manifest): """MK006: a private require of an engine module the mod has no permission for. Static rather than runtime because the loader's dev tripwire only sees the requires that actually execute during the entry chunk, and a require sitting inside a function body is the same reach past the API.""" declared = manifest.get("permissions") or [] granted = set(name for name in declared if isinstance(name, str)) \ if isinstance(declared, list) else set() supported = supported_requires(repo) findings = [] for rel in mod_files(mod_dir): if os.path.splitext(rel)[1].lower() != ".lua": continue body = strip_lua(open(os.path.join(mod_dir, rel), encoding="utf-8", errors="replace").read()) for match in REQUIRE_CALL.finditer(body): name = match.group(1).replace("/", ".") # the link modules are the one place a mod reaches the wire, so # network governs them; everything else under src. is internals if name.startswith("src.link."): needed = "network" elif name.startswith("src.") and name not in supported: needed = "engine_internals" else: continue if needed in granted: continue line = body.count("\n", 0, match.start()) + 1 findings.append(Finding( "MK006", "warn", f"private require of {name} without the {needed} permission; " f"declare it in manifest.json or use the mod API instead", f"{rel}:{line}")) return findings # ---------------------------------------------------------------- scaffold MANIFEST_TEMPLATE = """{ "id": "{{id}}", "name": "{{name}}", "version": "0.1.0", "api": 2, "entry": "main.lua", "profile": "{{profile}}", "game_version": ">={{game_version}} <{{next_major}}.0.0", "category": "GAMEPLAY", "priority": 100, "dependencies": [], "optional_dependencies": [], "conflicts": [], "incompatible": [], "experimental": {{experimental}},{{github_line}} "description": "TODO: one line about {{id}}"{{extra}} } """ # owner/repo or https://github.com/owner/repo(.git) GITHUB_RE = re.compile( r"^(?:https?://github\.com/)?([\w.\-]+)/([\w.\-]+?)(?:\.git)?/?$" ) def normalize_github(value): """Return 'owner/repo' or None for empty; raise ValueError if malformed.""" if value is None: return None text = str(value).strip() if not text: return None match = GITHUB_RE.fullmatch(text) if not match: raise ValueError( "github must be owner/repo or a github.com URL " f"(got {value!r})") owner, repo = match.group(1), match.group(2) if repo.endswith(".git"): repo = repo[:-4] return f"{owner}/{repo}" def check_github_field(manifest): """Optional github field: absent is fine (note), present must parse.""" findings, notes = [], [] raw = manifest.get("github") if raw is None or raw == "": notes.append( 'optional tip: set "github": "owner/repo" in manifest.json ' "to enable launcher auto-update and Other versions") return findings, notes try: normalize_github(raw) except ValueError as err: findings.append(Finding( "MK001", "error", str(err), "manifest.json")) return findings, notes MAIN_CONTENT = """-- {{id}}: a content-profile mod (api 2). -- The 10-minute loop: edit, save, F5 in a POKEPORT_DEV=1 game, repeat. return function(mod) -- patch, not override: every field you do not name keeps its base value -- (learnset, sprites, evolutions all survive this speed change) mod.content.pokemon:patch("MEW", { baseStats = { speed = 110 } }) -- mod.events:on("pokemon.caught", function(e) -- mod.log:info("caught %s at L%d", e.species, e.level) -- end) end """ MAIN_OVERHAUL = """-- {{id}}: an overhaul-profile mod (api 2). return function(mod) mod.options:define({ { key = "difficulty", label = "DIFFICULTY", kind = "choice", choices = { "normal", "hard" }, default = "normal" }, }) -- register into content registries here; patch beats override for -- anything you want to coexist with other mods -- mod.content.moves:patch("BLIZZARD", { accuracy = 70 }) -- mod.hooks:wrap("battle.damage", function(next, ctx, damage) -- return next(ctx, damage) -- end) -- mod.hooks:wrap("catch.rate", function(next, ctx, rate) -- return next(ctx, rate) -- end) end """ MAIN_TC = """-- {{id}}: a total-conversion-profile mod (api 2). return function(mod) -- the new game itself: spawn, names, money (field.boot, D11) -- mod.content.field:patch("boot", { -- startMap = "MY_TOWN", startX = 5, startY = 6, -- playerName = "HERO", rivalName = "FOE", startMoney = 5000, -- }) -- own the boot screens (Title/Intro) through the screens registry -- mod.content.screens:register("MyTitle", { new = function(game) ... end }) end """ TRANSFORMS_TEMPLATE = """-- Asset transforms ({{id}}): derive art from the PLAYER'S own imported -- cache at install time. Ship the recipe, never ROM-derived pixels -- -- this file is the only sanctioned way to base art on vanilla assets. return function(ctx) -- local img = ctx.readImage("battle/front/mew.png") -- ctx.recolor(img, { [2] = 3, [3] = 2 }) -- ctx.writeImage(img, "battle/front/mew.png") end """ LUARC_TEMPLATE = """{ "runtime.version": "LuaJIT", "diagnostics.globals": ["love"] } """ README_TEMPLATE = """# {{name}} A `{{profile}}` mod for the LOVE2D Pokemon Red engine (mod api 2). ## Layout - `manifest.json` - identity, version range, load order - `main.lua` - the entry chunk; receives the `mod` object {{layout_extra}} ## Loop 1. `POKEPORT_DEV=1 love .` once, leave it running 2. edit, press F5 to hot-reload, backtick for the dev console 3. `python3 tools/modkit.py validate {{id}}` before sharing 4. `python3 tools/modkit.py pack mods/{{id}}` to ship """ def cmd_scaffold(args, repo): profile = args.profile dest_root = args.dest or os.path.join(repo, "mods") dest = os.path.join(dest_root, args.id) if not re.fullmatch(r"[\w\-]+", args.id): print(f"modkit: bad id {args.id!r} (letters, numbers, _ or -)") return 2 if os.path.exists(dest) and not args.force: print(f"modkit: {dest} exists (use --force to overwrite)") return 2 engine = engine_version(repo) next_major = int(engine.split(".")[0]) + 1 name = args.id.replace("_", " ").replace("-", " ").title() github = "" if getattr(args, "github", None): try: github = normalize_github(args.github) or "" except ValueError as err: print(f"modkit: {err}") return 2 extra = "" if profile == "total_conversion": extra = ',\n "assets_transforms": "transforms.lua"' github_line = f'\n "github": "{github}",' if github else "" subst = { "{{id}}": args.id, "{{name}}": name, "{{profile}}": profile, "{{game_version}}": engine, "{{next_major}}": str(next_major), "{{extra}}": extra, "{{github_line}}": github_line, "{{experimental}}": "true" if getattr(args, "experimental", False) else "false", } def emit(rel, template): path = os.path.join(dest, rel) os.makedirs(os.path.dirname(path), exist_ok=True) body = template for key, value in subst.items(): body = body.replace(key, value) with open(path, "w", encoding="utf-8") as handle: handle.write(body) main = {"content": MAIN_CONTENT, "overhaul": MAIN_OVERHAUL, "total_conversion": MAIN_TC}[profile] layout_extra = "" if profile == "total_conversion": layout_extra = "- `transforms.lua` - asset transforms over the player's cache\n" subst["{{layout_extra}}"] = layout_extra emit("manifest.json", MANIFEST_TEMPLATE) emit("main.lua", main) emit("README.md", README_TEMPLATE) emit(".luarc.json", LUARC_TEMPLATE) os.makedirs(os.path.join(dest, "assets"), exist_ok=True) open(os.path.join(dest, "assets", ".gitkeep"), "w").close() if profile == "total_conversion": emit("transforms.lua", TRANSFORMS_TEMPLATE) if not args.quiet: print(f"created {dest} ({profile} profile, api 2)") print(f"next: python3 tools/modkit.py validate {args.id}") return 0 # ---------------------------------------------------------------- validate DRIVER_TEMPLATE = """-- generated by tools/modkit.py; drives the real loader headlessly package.path = "./?.lua;./?/init.lua;" .. package.path local data = %s local FILES = %s local overlay = {} local function readDisk(path) local disk = FILES[path] if not disk then return nil end local handle = io.open(disk, "rb") if not handle then return nil end local body = handle:read("*a") handle:close() return body end local fs = { read = function(path) return overlay[path] or readDisk(path) end, write = function(path, body) overlay[path] = body return true end, createDirectory = function() return true end, getInfo = function(path) if overlay[path] or FILES[path] then return { type = "file" } end local prefix = path .. "/" for key in pairs(FILES) do if key:sub(1, #prefix) == prefix then return { type = "directory" } end end return nil end, load = function(path) local body = overlay[path] or readDisk(path) if not body then return nil, "no file: " .. path end return loadstring(body, path) end, getDirectoryItems = function(path) local seen, items = {}, {} local prefix = path .. "/" for key in pairs(FILES) do if key:sub(1, #prefix) == prefix then local child = key:sub(#prefix + 1):match("^[^/]+") if child and not seen[child] then seen[child] = true items[#items + 1] = child end end end table.sort(items) return items end, } local Loader = require("src.mods.Loader") local Schemas = require("src.mods.Schemas") -- MK103 needs the id space as it stood BEFORE the merge: a patch against a -- missing id still folds to a value and lands in the target, so the merged -- view cannot tell an orphan from a real record local function resolvePath(root, path) local node = root for key in path:gmatch("[^%%.]+") do if type(node) ~= "table" then return nil end node = node[key] end return node end local baseIds = {} for name, spec in pairs(Schemas.REGISTRIES) do local set = {} local target = spec.target and resolvePath(data, spec.target) if type(target) == "table" then if spec.baseIds then for _, id in ipairs(spec.baseIds(target)) do set[id] = true end else for id in pairs(target) do set[id] = true end end end baseIds[name] = set end local loader = Loader.new({ fs = fs }) local ok, err = pcall(loader.load, loader, data) -- one tab-separated record per finding; each field is scrubbed on its own so -- the separators survive (a field that carried its own tab used to collapse -- the whole row into one column) local function row(kind, ...) local parts = { kind } for index = 1, select("#", ...) do local field = tostring((select(index, ...))) parts[#parts + 1] = (field:gsub("[\\t\\r\\n]", " ")) end print(table.concat(parts, "\\t")) end if not ok then row("ERR", err) end -- record registries only: deep ones treat patch as register (a new key is -- the point) and compose ones reject patch outright for name, registry in pairs(loader.content) do if registry.spec.semantics == "record" then local known = baseIds[name] or {} for id, list in pairs(registry.ops) do local defined, patcher = known[id], nil for _, entry in ipairs(list) do if entry.op == "register" or entry.op == "override" then defined = true elseif entry.op == "patch" and entry.owner ~= Schemas.ENGINE then patcher = patcher or entry.owner end end if patcher and not defined then row("ORPHAN", name, id, patcher) end end end end local Logger = require("src.core.Logger") for _, line in ipairs(Logger.history or {}) do if line:find("ignored:", 1, true) then row("IGN", line) elseif line:find("^%%[warn%%]") then row("WARN", line) end end local status = loader:status() for _, mod in ipairs(status.available) do row("MOD", mod.id, mod.version, mod.state, mod.error or "") end for _, message in ipairs(status.errors) do row("ERR", message) end """ def classify_error(message, fallback="MK100"): msg = message.lower() # a reference stranded by a tombstone is its own rule; the generic # dangling-ref test below would otherwise swallow it as MK102 if "unresolved reference to removed" in msg: return "MK104" if "unresolved reference" in msg: return "MK102" if "unknown permission" in msg: return "MK005" if ("unknown field" in msg or "missing required field" in msg or "expected" in msg): return "MK101" if "game version" in msg: return "MK002" if ("dependency" in msg or "circular" in msg): return "MK003" if "conflicts with" in msg: return "MK004" if "map_scripts" in msg: return "MK201" return fallback FIXTURE_BASE = 'require("tests.fixture_data").load()' IMPORTED_BASE = ('(function() local D = require("src.core.Data") ' 'D:load() return D end)()') def resolve_base(repo, choice): """--base auto prefers the player's imported dataset and falls back to the ROM-free fixture. Which one ran matters to MK103: the fixture is a three-species stand-in, so a missing id there proves nothing and the rule is skipped instead of reported.""" if choice != "auto": return choice imported = os.path.join(repo, "data", "generated", "pokemon.lua") return "imported" if os.path.isfile(imported) else "fixture" def run_loader(repo, mod_dir, findings, base="fixture", notes=None): """Drive the engine loader headlessly with the mod mounted; the base dataset is the ROM-free fixture, or the imported cache with --base imported (for mods that reference vanilla Red content). Rules that only the imported dataset can decide are skipped rather than downgraded when the fixture stands in, and each one names itself in notes so a skip is visible instead of silent.""" mount = "mods/" + os.path.basename(mod_dir) files = {} for rel in mod_files(mod_dir): files[f"{mount}/{rel}"] = os.path.join(mod_dir, rel) entries = "".join( " [%s] = %s,\n" % (lua_quote(k), lua_quote(v)) for k, v in sorted(files.items())) base = resolve_base(repo, base) source = IMPORTED_BASE if base == "imported" else FIXTURE_BASE driver = DRIVER_TEMPLATE % (source, "{\n" + entries + "}") with tempfile.NamedTemporaryFile("w", suffix=".lua", delete=False, encoding="utf-8") as handle: handle.write(driver) driver_path = handle.name try: proc = subprocess.run([LUAJIT, driver_path], cwd=repo, capture_output=True, text=True, timeout=120) except FileNotFoundError: findings.append(Finding("MK100", "error", f"cannot run {LUAJIT} (install luajit or " "set MODKIT_LUAJIT)")) return finally: os.unlink(driver_path) if proc.returncode != 0: findings.append(Finding("MK100", "error", "loader driver crashed: " + (proc.stderr or proc.stdout).strip()[-400:])) return # a failed mod reports the same message twice -- once in the error feed and # once as its own state -- so the same rule/text pair is emitted once seen = set() skipped = set() def add(finding): key = (finding.rule, finding.severity, finding.message) if key in seen: return seen.add(key) findings.append(finding) for line in proc.stdout.splitlines(): parts = line.split("\t") kind = parts[0] if kind == "ERR" and len(parts) > 1: message = parts[1] # check_permissions already named this one against manifest.json, # with the known set spelled out; the loader's echo adds nothing if "unknown permission" in message: continue add(Finding(classify_error(message), "error", message)) elif kind == "IGN" and len(parts) > 1: if "unknown permission" in parts[1]: continue add(Finding(classify_error(parts[1], "MK001"), "error", parts[1])) elif kind == "ORPHAN" and len(parts) >= 4: registry, target, owner = parts[1], parts[2], parts[3] # only the imported dataset owns the real vanilla id space. The # fixture stands in for three species, so "not in base data" there # is a fact about the fixture, not about the mod -- MK103 has no # evidence either way and does not get to speak. Emitting it as a # warning instead would still refuse the package, because pack and # --strict promote every warning to fatal. if base != "imported": skipped.add("MK103") continue add(Finding( "MK103", "error", f"{owner}: patch target {target!r} exists in neither " f"{registry} base data nor a dependency's registrations; " f"check the id spelling or depend on the mod that " f"registers it")) elif kind == "WARN" and len(parts) > 1: message = parts[1] if "unresolved reference" in message: # api 1 keeps cross-ref breakage at warning level; the rule id # still has to distinguish a tombstone from a plain typo add(Finding(classify_error(message), "warn", message)) elif "did you mean" in message or "schema" in message: add(Finding("MK101", "warn", message)) elif kind == "MOD" and len(parts) >= 4: mod_id, _version, state, error = (parts[1], parts[2], parts[3], "\t".join(parts[4:])) if state not in ("loaded", "disabled") and error: add(Finding(classify_error(error), "error", f"{mod_id}: {error}")) if skipped and notes is not None: notes.append( "%s not checked: the ROM-free fixture base only stands in for " "vanilla content, so it cannot tell a typo from a real id -- " "re-run with --base imported to check %s" % (", ".join(sorted(skipped)), "them" if len(skipped) > 1 else "it")) def lua_quote(text): return '"' + (text.replace("\\", "\\\\").replace('"', '\\"')) + '"' def cmd_validate(args, repo): mod_dir = resolve_mod_dir(repo, args.mod) if not mod_dir: print(f"modkit: no mod at {args.mod!r}") return 2 findings = [] notes = [] manifest, problem = read_manifest(mod_dir) if problem: findings.append(problem) else: gh_findings, gh_notes = check_github_field(manifest) findings.extend(gh_findings) notes.extend(gh_notes) findings.extend(check_permissions(repo, manifest)) run_loader(repo, mod_dir, findings, args.base, notes) findings.extend(check_requires(repo, mod_dir, manifest)) findings.extend(lint_dir(repo, mod_dir, manifest)) name = manifest.get("id") if manifest else os.path.basename(mod_dir) return report(findings, args, f"ok {name} valid", f"FAIL {name} invalid", notes) def write_manifest(mod_dir, manifest): path = os.path.join(mod_dir, "manifest.json") with open(path, "w", encoding="utf-8") as handle: json.dump(manifest, handle, indent=2, ensure_ascii=False) handle.write("\n") def cmd_set_github(args, repo): """Add or update the optional github field on an existing manifest.""" mod_dir = resolve_mod_dir(repo, args.mod) if not mod_dir: print(f"modkit: no mod at {args.mod!r}") return 2 manifest, problem = read_manifest(mod_dir) if problem: print(problem.line()) return 1 try: repo_slug = normalize_github(args.url) except ValueError as err: print(f"modkit: {err}") return 2 if not repo_slug: print("modkit: github url is empty") return 2 manifest["github"] = repo_slug write_manifest(mod_dir, manifest) if not args.quiet: print(f"set github to {repo_slug!r} in {mod_dir}/manifest.json") print("launcher auto-update / Other versions will use this repo") return 0 def cmd_add_release_workflow(args, repo): """Copy the standard GitHub Actions release workflow into a mod folder.""" mod_dir = resolve_mod_dir(repo, args.mod) if not mod_dir: print(f"modkit: no mod at {args.mod!r}") return 2 manifest, problem = read_manifest(mod_dir) if problem: print(problem.line()) return 1 mod_id = manifest.get("id") or os.path.basename(mod_dir) template = os.path.join(repo, "tools", "mod_release_workflow.yml") if not os.path.isfile(template): print(f"modkit: missing template {template}") return 2 dest_dir = os.path.join(mod_dir, ".github", "workflows") dest = os.path.join(dest_dir, "release.yml") if os.path.exists(dest) and not args.force: print(f"modkit: {dest} exists (use --force to overwrite)") return 2 body = open(template, encoding="utf-8").read().replace("{{MOD_ID}}", mod_id) os.makedirs(dest_dir, exist_ok=True) with open(dest, "w", encoding="utf-8") as handle: handle.write(body) if not args.quiet: print(f"wrote {dest}") print("push this mod as its own GitHub repo (with manifest github set) " "to publish installable .zip releases on every main push") return 0 # ---------------------------------------------------------------- lint def ahash(image): """Ink-mask hash over the 8x8 downscale: background (the lightest GB shade) vs ink. Swapping the three ink shades -- the classic recolor -- leaves the mask intact, which is exactly what MK302 wants to catch.""" from PIL import Image small = image.convert("L").resize((8, 8), Image.LANCZOS) raw = (small.get_flattened_data() if hasattr(small, "get_flattened_data") else small.getdata()) return sum((1 << i) for i, p in enumerate(raw) if p <= 200) def hamming(a, b): return bin(a ^ b).count("1") class CacheIndex: """Hashes of the player's ROM-derived cache (assets/generated).""" def __init__(self, repo): self.sha = {} self.perceptual = [] root = os.path.join(repo, "assets", "generated") if not os.path.isdir(root): return try: from PIL import Image except ImportError: Image = None for base, _dirs, files in os.walk(root): for name in files: path = os.path.join(base, name) rel = os.path.relpath(path, repo).replace(os.sep, "/") body = open(path, "rb").read() self.sha[hashlib.sha256(body).hexdigest()] = rel if Image and os.path.splitext(name)[1].lower() in IMAGE_EXTS: try: with Image.open(io.BytesIO(body)) as img: self.perceptual.append( (rel, img.size, ahash(img))) except Exception: pass def lint_dir(repo, mod_dir, manifest): """MK3xx: the no-ROM-content gate (22-distribution-and-packaging.md).""" findings = [] manifest = manifest or {} transforms_rel = manifest.get("assets_transforms") has_transforms = bool(transforms_rel) cache = CacheIndex(repo) try: from PIL import Image except ImportError: Image = None for rel in mod_files(mod_dir): path = os.path.join(mod_dir, rel) ext = os.path.splitext(rel)[1].lower() # MK301: nothing may live in (or point into) the generated trees if rel.startswith(("data/generated/", "assets/generated/")): findings.append(Finding( "MK301", "error", "path shadows the player's ROM-derived cache", rel)) continue if ext in (".lua", ".json") and rel != transforms_rel: body = open(path, encoding="utf-8", errors="replace").read() if "assets/generated/" in body or "data/generated/" in body: findings.append(Finding( "MK301", "error", "references the ROM-derived cache; ship your own asset " "under assets/ or derive it via assets_transforms", rel)) # MK303: ROM images and ROM-hack patch formats never ship if ext in ROM_PATCH_EXTS: findings.append(Finding( "MK303", "error", "ROM/ROM-hack patch file", rel)) continue # MK304: raw chip-audio banks are ROM-derived base = os.path.basename(rel) if base == "programs.bin": findings.append(Finding( "MK304", "error", "raw audio bank blob (author chip programs instead)", rel)) continue if ext == ".bin": size = os.path.getsize(path) if size >= 0x4000 and size % 0x4000 == 0: findings.append(Finding( "MK304", "error", "bank-sized binary blob looks ROM-derived", rel)) continue # MK302: byte-identity and perceptual near-duplicates vs the cache if ext in ASSET_EXTS: body = open(path, "rb").read() digest = hashlib.sha256(body).hexdigest() twin = cache.sha.get(digest) if twin: findings.append(Finding( "MK302", "error", f"byte-identical to ROM-derived {twin}", rel)) continue if Image and ext in IMAGE_EXTS and cache.perceptual: try: with Image.open(io.BytesIO(body)) as img: size, digest = img.size, ahash(img) except Exception: continue for twin_rel, twin_size, twin_hash in cache.perceptual: if size == twin_size and hamming(digest, twin_hash) <= 4: severity = "warn" if has_transforms else "error" remedy = ("allowed (ships assets_transforms)" if has_transforms else "ship it as an assets_transforms step " "instead of a file") findings.append(Finding( "MK302", severity, f"near-duplicate of ROM-derived {twin_rel} -- " f"{remedy}", rel)) break # MK305: bulk dump of an imported data table if (ext == ".lua" and os.path.splitext(base)[0] in GENERATED_MODULES and rel != transforms_rel and rel != "main.lua"): finding = check_data_dump(repo, path, base, rel) if finding: findings.append(finding) return findings DUMP_DRIVER = """local function keysOf(path) local handle = io.open(path, "rb") if not handle then return nil end local body = handle:read("*a") handle:close() local chunk = loadstring(body, path) if not chunk then return nil end setfenv(chunk, {}) local ok, result = pcall(chunk) if not ok or type(result) ~= "table" then return nil end local keys = {} for key in pairs(result) do if type(key) == "string" then keys[#keys + 1] = key end end return keys end local shipped = keysOf(%s) local vanilla = keysOf(%s) if not shipped or not vanilla or #vanilla < 10 then return print("SKIP") end local set = {} for _, key in ipairs(shipped) do set[key] = true end local hits = 0 for _, key in ipairs(vanilla) do if set[key] then hits = hits + 1 end end print(hits >= #vanilla * 0.8 and "DUMP" or "OK") """ def check_data_dump(repo, path, base, rel): vanilla = os.path.join(repo, "data", "generated", base) if not os.path.isfile(vanilla): # no imported dataset to diff against; say so rather than pass # silently, so a green run never implies this rule actually ran return Finding("MK305", "warn", f"dump check skipped: no imported data/generated/{base} " "to diff against", rel) driver = DUMP_DRIVER % (lua_quote(path), lua_quote(vanilla)) try: proc = subprocess.run([LUAJIT, "-e", driver], cwd=repo, capture_output=True, text=True, timeout=60) except FileNotFoundError: # the gate must fail closed: a missing interpreter is a broken # environment, not a clean mod return Finding("MK100", "error", f"cannot run {LUAJIT} for the dump check (install " "luajit or set MODKIT_LUAJIT)", rel) if proc.stdout.strip() == "DUMP": return Finding("MK305", "error", "bulk dump of an imported data table; register " "individual records through the mod API", rel) return None def cmd_lint(args, repo): mod_dir = resolve_mod_dir(repo, args.mod) if not mod_dir: print(f"modkit: no mod at {args.mod!r}") return 2 manifest, problem = read_manifest(mod_dir) findings = [problem] if problem else [] findings.extend(lint_dir(repo, mod_dir, manifest)) name = os.path.basename(mod_dir) return report(findings, args, f"ok {name}: no ROM-derived content", f"FAIL {name}: ROM-content gate") # ---------------------------------------------------------------- pack def cmd_pack(args, repo): mod_dir = resolve_mod_dir(repo, args.mod) if not mod_dir: print(f"modkit: no mod at {args.mod!r}") return 2 manifest, problem = read_manifest(mod_dir) if problem: print(problem.line()) return 1 findings = list(check_permissions(repo, manifest)) notes = [] run_loader(repo, mod_dir, findings, args.base, notes) findings.extend(check_requires(repo, mod_dir, manifest)) findings.extend(lint_dir(repo, mod_dir, manifest)) # pack runs validate --strict (20-developer-tooling.md 5), so a warning # blocks distribution too: MK006 and the MK3xx gate are documented as # unbypassable by the packaging path, which only holds if warnings bite # here even though they are advisory under a bare validate. Notes are not # findings -- a rule the fixture base could not run has nothing to say # about the mod, so packing ROM-free stays possible (M13 criterion 4) for f in findings: print(f.line()) if not args.quiet: for note in notes: print(f"modkit: {note}") if findings: if not args.quiet: print("modkit: pack refused (pack runs validate --strict, so the " "warnings above are fatal too)") return 1 mod_id = manifest["id"] version = manifest.get("version", "0.0.0") out = args.output or f"{mod_id}-{version}.modpkg" files = mod_files(mod_dir) records = [] for rel in files: body = open(os.path.join(mod_dir, rel), "rb").read() records.append({"path": rel, "bytes": len(body), "sha256": hashlib.sha256(body).hexdigest()}) pack_meta = { "modkit": MODKIT_VERSION, "packed_at": datetime.now(timezone.utc) .strftime("%Y-%m-%dT%H:%M:%SZ"), "id": mod_id, "version": version, "api": manifest.get("api", 1), "engine_range": manifest.get("game_version", ""), "files": records, "lint": {"no_rom_content": "pass", "schema": "pass", "cross_refs": "pass"}, } # normalized entry order + a fixed timestamp = reproducible archives with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as archive: for rel in files: info = zipfile.ZipInfo(rel, date_time=(1980, 1, 1, 0, 0, 0)) info.compress_type = zipfile.ZIP_DEFLATED info.external_attr = 0o644 << 16 archive.writestr(info, open(os.path.join(mod_dir, rel), "rb").read()) info = zipfile.ZipInfo(".modkit/pack.json", date_time=(1980, 1, 1, 0, 0, 0)) info.compress_type = zipfile.ZIP_DEFLATED info.external_attr = 0o644 << 16 archive.writestr(info, json.dumps(pack_meta, indent=2)) if not args.quiet: print(f"wrote {out} (reproducible, {len(files)} files " "+ .modkit/pack.json)") return 0 # ---------------------------------------------------------------- bounce BOUNCE_DRIVER = """-- generated by tools/modkit.py bounce package.path = "./?.lua;./?/init.lua;" .. package.path love = require("tests.love_stub") -- the render seam reads programs.bin through love.filesystem; back it -- with the real disk for this offline run love.filesystem.read = function(path) local handle = io.open(path, "rb") if not handle then return nil, "no file: " .. path end local body = handle:read("*a") handle:close() return body end love.filesystem.getInfo = function(path) local handle = io.open(path, "rb") if handle then handle:close() return { type = "file" } end return nil end local Data = require("src.core.Data") local ok, err = pcall(Data.load, Data) if not ok then io.stderr:write("bounce needs an imported dataset: " .. tostring(err) .. "\\n") os.exit(3) end local ChipAudio = require("src.core.ChipAudio") local songs = Data.audio and Data.audio.songs or {} local WANTED = %s local SECONDS = %d local OUT = %s local function isChip(def) return type(def) == "table" and (def.chip ~= nil or (def.address and def.bank) or def.program) end local function writeWav(path, sd) local samples = sd:getSampleCount() local channels = sd:getChannelCount() local rate = sd:getSampleRate() local dataBytes = samples * channels * 2 local function u32(n) return string.char(n %% 256, math.floor(n / 256) %% 256, math.floor(n / 65536) %% 256, math.floor(n / 16777216) %% 256) end local function u16(n) return string.char(n %% 256, math.floor(n / 256) %% 256) end local handle = assert(io.open(path, "wb")) handle:write("RIFF", u32(36 + dataBytes), "WAVE") handle:write("fmt ", u32(16), u16(1), u16(channels), u32(rate), u32(rate * channels * 2), u16(channels * 2), u16(16)) handle:write("data", u32(dataBytes)) local chunk = {} for index = 0, samples - 1 do for channel = 1, channels do local value = sd:getSample(index, channel) local int = math.floor(value * 32767 + 0.5) if int < -32768 then int = -32768 end if int > 32767 then int = 32767 end if int < 0 then int = int + 65536 end chunk[#chunk + 1] = u16(int) end if #chunk >= 8192 then handle:write(table.concat(chunk)) chunk = {} end end handle:write(table.concat(chunk)) handle:close() end local ids = {} if WANTED then ids[1] = WANTED else for id in pairs(songs) do ids[#ids + 1] = id end table.sort(ids) end local rendered, skipped = 0, 0 for _, id in ipairs(ids) do local def = songs[id] if not def then io.stderr:write("no such song: " .. id .. "\\n") os.exit(1) end if isChip(def) then local okRender, sd = pcall(ChipAudio._renderMusicForTest, Data, def, SECONDS) if okRender and sd then writeWav(OUT .. "/" .. id .. ".wav", sd) print("wrote " .. OUT .. "/" .. id .. ".wav") rendered = rendered + 1 else io.stderr:write("render failed for " .. id .. ": " .. tostring(sd) .. "\\n") end else skipped = skipped + 1 end end print(("bounced %%d songs (%%d file-based skipped)"):format(rendered, skipped)) """ def cmd_bounce(args, repo): out_dir = args.out or os.path.join(repo, "bounce") os.makedirs(out_dir, exist_ok=True) wanted = "nil" if args.all else lua_quote(args.song) driver = BOUNCE_DRIVER % (wanted, args.seconds, lua_quote(out_dir)) with tempfile.NamedTemporaryFile("w", suffix=".lua", delete=False, encoding="utf-8") as handle: handle.write(driver) driver_path = handle.name try: proc = subprocess.run([LUAJIT, driver_path], cwd=repo) finally: os.unlink(driver_path) return 0 if proc.returncode == 0 else 1 # ----------------------------------------------------------- translation # Dumps the player-facing tables out of a loaded dataset as TSV, both fields # already Lua-quoted so the generator can paste them straight into the # catalogs without a second round of escaping. TRANSLATION_DUMP = """-- generated by tools/modkit.py; dumps translatable data as TSV package.path = "./?.lua;./?/init.lua;" .. package.path if not _G.love then _G.love = require("tests.love_stub") end local D = {{BASE}} -- Not %q: that escapes a newline as a backslash followed by a real line -- break, which would split every dex entry across several TSV rows and -- silently truncate it. Escape by hand so a value is always one line, and -- keep the readable spellings (\\n, not \\10) because these land in the -- comment a translator reads. local ESCAPES = { ["\\\\"] = "\\\\\\\\", ['"'] = '\\\\"', ["\\n"] = "\\\\n", ["\\r"] = "\\\\r", ["\\t"] = "\\\\t" } local function esc(s) local body = s:gsub('[%c\\\\"]', function(c) return ESCAPES[c] or ("\\\\%d"):format(c:byte()) end) return '"' .. body .. '"' end local function emit(kind, key, value) if type(value) ~= "string" or value == "" then return end io.write(kind, "\\t", esc(key), "\\t", esc(value), "\\n") end for id, text in pairs(D.text or {}) do emit("dialogue", id, text) end for id, def in pairs(D.pokemon or {}) do emit("species", id, def.name) end for id, def in pairs(D.moves or {}) do emit("move", id, def.name) end for id, def in pairs(D.items or {}) do emit("item", id, def.name) end for id, def in pairs(D.trainers or {}) do emit("trainer", id, def.name) end -- Data.statuses only exists once the mod merge has run, so fall back to the -- engine's own records: they are what a mod-free boot puts there anyway. local statuses = D.statuses if not statuses or next(statuses) == nil then statuses = require("src.battle.Status").RECORDS end for id, def in pairs(statuses or {}) do emit("status", id, def.label) if def.hudLabel and def.hudLabel ~= def.label then emit("status_hud", id, def.hudLabel) end end -- dex entries carry their own prose (species flavour text) for id, def in pairs(D.pokemon or {}) do if type(def.dexEntry) == "table" then emit("dex", id, def.dexEntry.kind) emit("dex_text", id, def.dexEntry.text) end end """ # The engine's own literals, harvested from the Strings(...) call sites. # Matches Strings("...") and Strings('...'), single line, which is how the # sweep writes them; a call whose source string is built at runtime cannot # be translated and is not meant to match here. STRINGS_CALL = re.compile( r'\bStrings(?:\.source)?\(' r'\s*("(?:[^"\\]|\\.)*"|\'(?:[^\'\\]|\\.)*\')\s*(?:,|\))') def _fold_ascii(text): folded = unicodedata.normalize("NFKD", text or "") folded = "".join(c for c in folded if not unicodedata.combining(c)) folded = re.sub(r"[^A-Za-z0-9_-]+", "_", folded).strip("_-") return folded.lower() def ascii_mod_id(name, language=None): r"""An engine-legal manifest id derived from what the author called it. src/mods/Manifest.lua matches `^[%w_%-]+$`, and Lua's %w is ASCII where Python's \w is not, so "VersaoVermelha" with a tilde loads fine by Python's rules and is rejected by the engine's. The directory keeps the author's name (the loader keys on manifest.id, not the folder); this is only the id. Accented Latin folds cleanly. A name written entirely in a non-Latin script does not, and those are precisely the translations worth supporting, so fall back to the --language name and then to a stable digest rather than refusing to scaffold.""" for candidate in (_fold_ascii(name), _fold_ascii(language)): if candidate: return candidate digest = hashlib.sha1((name or "").encode("utf-8")).hexdigest()[:8] return f"translation_{digest}" def harvest_engine_strings(repo): """Every literal the engine passes through src/core/Strings.lua, in source order per file. Returns [(lua_literal, "path:line"), ...].""" out, seen = [], set() src = os.path.join(repo, "src") for root, _dirs, names in os.walk(src): for name in sorted(names): if not name.endswith(".lua"): continue path = os.path.join(root, name) rel = os.path.relpath(path, repo) with open(path, encoding="utf-8") as handle: body = handle.read() for match in STRINGS_CALL.finditer(body): literal = match.group(1) if literal.startswith("'"): # normalise to the double-quoted form the catalog uses literal = '"' + literal[1:-1].replace('"', '\\"') + '"' line = body.count("\n", 0, match.start()) + 1 if literal in seen: continue seen.add(literal) out.append((literal, f"{rel}:{line}")) return out def dump_dataset(repo, base): """Run the dumper under luajit against the fixture or imported cache.""" source = IMPORTED_BASE if base == "imported" else FIXTURE_BASE body = TRANSLATION_DUMP.replace("{{BASE}}", source) handle = tempfile.NamedTemporaryFile("w", suffix=".lua", delete=False, dir=repo, encoding="utf-8") handle.write(body) handle.close() try: proc = subprocess.run([os.environ.get("LUA", "luajit"), handle.name], cwd=repo, capture_output=True, text=True) finally: os.unlink(handle.name) if proc.returncode != 0: raise RuntimeError(proc.stderr.strip() or "dataset dump failed") rows = [] for line in proc.stdout.splitlines(): parts = line.split("\t") if len(parts) == 3: rows.append(tuple(parts)) return rows def _catalog_file(title, note, entries, keyed_by_source=False): """One lang/*.lua table: every value starts empty, and main.lua skips empties so an unfinished catalog falls through to English. The English is deliberately NOT written alongside the ROM-derived keys. Extracted script text and vanilla names are ROM content, and a mod that shipped 2500 lines of them in comments would be redistributing the ROM however good the intent. Those go to worksheet/, which is gitignored and never packed. Engine-authored sources (lang/strings.lua) are this repo's own Lua, so there the key IS the English and nothing is leaked.""" out = [f"-- {title}", "--"] out += ["-- " + line for line in note.strip().splitlines()] out += ["", "return {"] if not entries: out.append(" -- nothing to translate here yet") for key, _english in entries: out.append(f" [{key}] = \"\",") out += ["}", ""] return "\n".join(out) TRANSLATION_MAIN = '''-- {{name}}: a translation of the game into {{lang}}. -- -- Nothing here is translated yet. Every table under lang/ starts with -- empty strings; fill one in and it takes effect on the next boot, and -- anything still empty keeps rendering in English. That means a -- half-finished translation is always playable, so you can ship early and -- fill the long tail in later. -- -- Read TRANSLATING.md before the first edit; the font is the part people -- get wrong. return function(mod) -- mod:read is the supported way into your own directory; the catalogs are -- plain Lua tables, so read and run them rather than require()ing them. local function catalog(name) local rel = "lang/" .. name .. ".lua" local body = mod:read(rel) if not body then return {} end local chunk, err = loadstring(body, rel) if not chunk then mod.log:warn("%s has a syntax error: %s", rel, tostring(err)) return {} end local ok, table_ = pcall(chunk) if not ok or type(table_) ~= "table" then mod.log:warn("%s did not return a table: %s", rel, tostring(table_)) return {} end return table_ end -- An empty value means "not translated yet", never "translate to blank". local function each(name, apply) local n = 0 for key, value in pairs(catalog(name)) do if type(value) == "string" and value ~= "" then apply(key, value) n = n + 1 end end return n end -- ---- glyphs ------------------------------------------------------- -- Register the sheet BEFORE anything asks for a glyph on it. base is -- the first code the page owns; 0x100 and up is free space above the -- vanilla pages, so a new alphabet never collides with them. for id, page in pairs(catalog("font")) do mod.content.font:register(id, page) end -- charmap: which byte sequence draws which code for seq, code in pairs(catalog("charmap")) do mod.content.font:register("charmap:" .. seq, { seq = seq, code = code }) end -- ---- text --------------------------------------------------------- local counts = {} counts.dialogue = each("dialogue", function(id, value) mod.content.text:override(id, value) end) counts.strings = each("strings", function(source, value) mod.content.strings:override(source, value) end) counts.species = each("species_names", function(id, value) mod.content.pokemon:patch(id, { name = value }) end) counts.moves = each("move_names", function(id, value) mod.content.moves:patch(id, { name = value }) end) counts.items = each("item_names", function(id, value) mod.content.items:patch(id, { name = value }) end) counts.trainers = each("trainer_names", function(id, value) mod.content.trainers:patch(id, { name = value }) end) counts.statuses = each("status_labels", function(id, value) mod.content.statuses:patch(id, { label = value }) end) -- ---- name entry --------------------------------------------------- -- The naming screen's letter grid. Leave lang/naming.lua returning nil -- to keep the English alphabet. local grid = catalog("naming") if grid.upper then mod.hooks:on("ui.naming.grid", function(base, ctx) local want = ctx.lower and grid.lower or grid.upper return want or base end) end mod.events:on("game.ready", function() local total = 0 for _, n in pairs(counts) do total = total + n end mod.log:info("{{lang}}: %d strings translated", total) end) end ''' TRANSLATING_MD = '''# Translating into {{lang}} Everything the player can read is one of two kinds of string, and they live in different places for a reason. | lang/ file | What it is | Key | |---|---|---| | `dialogue.lua` | Every line of extracted script text | the original label, e.g. `_PalletTownText1` | | `strings.lua` | Text the engine itself writes: battle messages, menus, link play | the English source string | | `species.lua` `moves.lua` `items.lua` `trainers.lua` | Names | the vanilla id | | `statuses.lua` | `PSN`, `BRN`, ... as they appear in the HUD | the status id | | `font.lua` `charmap.lua` | Your glyph sheet and what draws what | see below | | `naming.lua` | The letter grid for entering names | - | Fill in a value and it takes effect. Leave it `""` and that string stays in English, so the game is playable at every point along the way. ## Where the English is The catalogs hold keys and *your* text, never the original English. The English lives next door, in `{{id}}-worksheet/`, one tab-separated file per catalog: ``` "_AbandonLearningText"\t"Abandon learning\\n{RAM:wStringBuffer}?" ``` That directory is deliberately outside the mod. Extracted script text and the vanilla names are ROM content, and `modkit pack` zips everything under the mod directory, so a worksheet kept inside would end up in your release whatever a `.gitignore` said. Keep it beside the mod, never in it. `lang/strings.lua` is the exception: those sources are the engine's own Lua rather than anything out of the ROM, so there the key *is* the English and you can translate straight from it. ## Start with the font, not the text The engine draws from **glyph pages**: an image of 8x8 cells plus a charmap saying which byte sequence draws which cell. The vanilla pages sit at `$60` and `$80`. Anything from `0x100` up is free, so a new alphabet is added rather than swapped in: ```lua -- lang/font.lua return { {{lang_id}} = { image = "assets/font/{{lang_id}}.png", base = 0x100, -- first code this page owns glyphsPerRow = 16, -- advance = 8, -- set this if your glyphs are not 8px wide }, } ``` ```lua -- lang/charmap.lua: sequence -> code, in the same order as the sheet return { ["A"] = 0x100, ["B"] = 0x101, } ``` The sheet is a plain PNG, 16 glyphs to a row by default, each cell 8x8, black on white like `assets/generated/font.png`. Codes run left to right, top to bottom from `base`. Sequences are matched **longest first**, so a multi-byte character and a multi-character ligature both work and neither shadows the other: ```lua ["\\u{3042}"] = 0x120, -- one 3-byte character, one glyph ["ch"] = 0x121, -- two ASCII letters, one glyph ``` ## Line length is counted in glyphs The dialogue box fits 18 glyphs a line, not 18 bytes. A 3-byte character costs one column, and the engine will never cut a character in half. Your own `\\n` line breaks are respected exactly as written, so break lines where they read best rather than where they fit English. If your glyphs are not 8px wide, set `advance` on the page and the box re-measures. ## Format directives must survive Some sources carry `%s` or `%d`: ```lua ["Wild %s\\nappeared!"] = "...", ``` Keep every directive, in a count that matches. Word order is yours to change; the engine substitutes in the order the directives appear, so if your language needs the name last, write the sentence with the `%s` last. A translation whose directive count does not match the English is refused at runtime and the English is drawn instead, with a line in the log saying so - it will not crash a battle. ## Checking your work ```sh python3 tools/modkit.py validate {{id}} --base imported python3 tools/modkit.py translation {{id}} --refresh # pick up new engine strings POKEPORT_DEV=1 scripts/run.sh # F5 hot-reloads lang/ ``` `--refresh` rewrites the catalogs from the current engine, keeping every translation you have already written and reporting what changed. Run it after pulling a new engine version. ''' TRANSLATION_README = '''# {{name}} A {{lang}} translation of the game. Generated with `python3 tools/modkit.py translation {{id}}`. See `TRANSLATING.md` for how to work on it. ## Status Nothing is translated yet: {{total}} strings are waiting in `lang/`. | Catalog | Entries | |---|---| {{table}} ## Layout - `manifest.json` - identity and the engine version range - `main.lua` - registers whatever is filled in and skips whatever is not - `lang/` - the catalogs; this is the whole job - `assets/font/` - your glyph sheet ''' FONT_README = '''Put your glyph sheet here. A page is a PNG of 8x8 cells, 16 per row by default, black on white. Codes run left to right and top to bottom starting at the page's `base`, so the first cell is `base`, the second `base + 1`, and so on. `assets/generated/font.png` in the player's cache is the vanilla sheet at the same scale; open it alongside yours to match weight and baseline. Declare the sheet in `lang/font.lua` and map sequences to codes in `lang/charmap.lua`. ''' def cmd_translation(args, repo): """Scaffold (or refresh) a translation mod: every player-visible string the engine and the dataset know about, as empty catalogs to fill in.""" dest = os.path.join(args.dest or os.path.join(repo, "mods"), args.id) # Translations get named in the language they translate into # ("VersaoVermelha", with the tilde), but the engine's manifest rule is # Lua's `^[%w_%-]+$`, and Lua's %w is ASCII-only where Python's \w is not. # A directory named in the target language is fine -- the loader keys on # manifest.id, not the folder -- so keep the name the author asked for and # derive an ASCII id for the manifest. mod_id = ascii_mod_id(args.id, args.language) exists = os.path.exists(dest) if exists and not (args.refresh or args.force): print(f"modkit: {dest} exists (use --refresh to update the catalogs)") return 2 base = resolve_base(repo, args.base) try: rows = dump_dataset(repo, base) except RuntimeError as err: print(f"modkit: could not read the dataset ({err})") return 1 grouped = {} for kind, key, value in rows: grouped.setdefault(kind, []).append((key, value)) for entries in grouped.values(): entries.sort() engine = harvest_engine_strings(repo) catalogs = [ ("dialogue", "Script text", grouped.get("dialogue", []), False, "Keyed by the original text label. The English is in the comment."), ("strings", "Engine text", [(lit, where) for lit, where in engine], True, "Keyed by the English source, which is also what draws if you leave\n" "an entry empty. Keep any %s / %d directives."), ("species_names", "Species names", grouped.get("species", []), False, ""), ("move_names", "Move names", grouped.get("move", []), False, ""), ("item_names", "Item names", grouped.get("item", []), False, ""), ("trainer_names", "Trainer class names", grouped.get("trainer", []), False, ""), ("status_labels", "Status labels", grouped.get("status", []), False, "Short enough for the battle HUD: the vanilla ones are three glyphs."), ] # keep existing translations across a --refresh previous = {} if exists: for name, *_ in catalogs: path = os.path.join(dest, "lang", f"{name}.lua") previous[name] = _read_existing_catalog(path) os.makedirs(os.path.join(dest, "lang"), exist_ok=True) os.makedirs(os.path.join(dest, "assets", "font"), exist_ok=True) lang_name = args.language or args.id.replace("_", " ").title() counts, changed, kept = {}, {}, {} for name, title, entries, by_source, note in catalogs: done = previous.get(name, {}) body = _catalog_file(title, note or f"{title} for {lang_name}.", entries, by_source) if done: body = _merge_catalog(body, done) path = os.path.join(dest, "lang", f"{name}.lua") with open(path, "w", encoding="utf-8") as handle: handle.write(body) counts[name] = len(entries) kept[name] = sum(1 for key in done if any(key == k for k, _ in entries)) changed[name] = len(done) - kept[name] engine_version_ = engine_version(repo) subst = { "{{id}}": mod_id, "{{name}}": args.id, "{{lang}}": lang_name, "{{lang_id}}": re.sub(r"\W+", "_", args.id).lower(), "{{game_version}}": engine_version_, "{{next_major}}": str(int(engine_version_.split(".")[0]) + 1), "{{profile}}": "content", "{{extra}}": "", "{{github_line}}": "", "{{experimental}}": "false", "{{total}}": str(sum(counts.values())), "{{table}}": "\n".join( f"| `lang/{name}.lua` | {counts[name]} |" for name, *_ in catalogs), } def emit(rel, template, overwrite=True): path = os.path.join(dest, rel) if os.path.exists(path) and not overwrite: return os.makedirs(os.path.dirname(path), exist_ok=True) body = template for key, value in subst.items(): body = body.replace(key, value) with open(path, "w", encoding="utf-8") as handle: handle.write(body) # A refresh must never clobber hand-edited prose or a tuned manifest. emit("manifest.json", MANIFEST_TEMPLATE, overwrite=not exists) emit("main.lua", TRANSLATION_MAIN, overwrite=not exists) emit("README.md", TRANSLATION_README, overwrite=not exists) emit("TRANSLATING.md", TRANSLATING_MD) emit("assets/font/README.md", FONT_README) emit(".luarc.json", LUARC_TEMPLATE, overwrite=not exists) for stub, body in (("font", FONT_STUB), ("charmap", CHARMAP_STUB), ("naming", NAMING_STUB)): emit(f"lang/{stub}.lua", body, overwrite=not exists) # The English reference, written as a SIBLING of the mod rather than # inside it. Extracted text is ROM content: it can sit on the # translator's disk, but `modkit pack` zips the whole mod directory, so # anything under dest/ would end up in the distributable no matter what # a .gitignore said. Keeping it outside is the only version of this # that cannot leak. work = dest + "-worksheet" os.makedirs(work, exist_ok=True) for name, title, entries, by_source, _note in catalogs: if by_source: continue # engine strings are our own source, already readable lines = [f"# {title}: the English behind each key in lang/{name}.lua.", "# Reference only, and deliberately outside the mod: this", "# text comes out of the ROM, so it must not be shipped.", ""] for key, english in entries: lines.append(f"{key}\t{english}") with open(os.path.join(work, f"{name}.txt"), "w", encoding="utf-8") as handle: handle.write("\n".join(lines) + "\n") if args.json: print(json.dumps({"dest": dest, "base": base, "counts": counts, "kept": kept, "orphaned": changed}, indent=2)) elif not args.quiet: verb = "refreshed" if exists else "created" print(f"{verb} {dest} ({base} dataset)") for name, *_ in catalogs: line = f" lang/{name}.lua {counts[name]:5} entries" if exists: line += f" ({kept[name]} translated" if changed[name]: line += f", {changed[name]} orphaned" line += ")" print(line) if base == "fixture": print("\nnote: no imported dataset found, so the name and dialogue") print("catalogs came from the three-species test fixture.") print("Import a ROM and re-run with --refresh for the real set.") print(f"\nnext: read {os.path.join(dest, 'TRANSLATING.md')}") return 0 FONT_STUB = '''-- Glyph pages this translation adds. Delete the entry if the vanilla -- alphabet already covers your language. -- -- base is the first glyph code the page owns. 0x100 and up is free space -- above the vanilla $60/$80 pages, so this adds an alphabet rather than -- replacing one. Set `advance` if your glyphs are not 8px wide. return { -- {{lang_id}} = { -- image = "assets/font/{{lang_id}}.png", -- base = 0x100, -- glyphsPerRow = 16, -- }, } ''' CHARMAP_STUB = '''-- Which byte sequence draws which glyph code. -- -- Sequences are matched longest-first, so a multi-byte character and a -- multi-character ligature both work: "ch" can be one glyph even though -- "c" is also mapped. Codes here must land inside a page declared in -- lang/font.lua. return { -- ["A"] = 0x100, -- ["B"] = 0x101, } ''' NAMING_STUB = '''-- The naming screen's letter grid. Return an empty table to keep the -- English alphabet. -- -- Each entry is a row of cells; a cell is whatever sequence your charmap -- maps, so a multi-byte character is one cell. The row holding a single -- "lower case" / "UPPER CASE" cell is the case switch, and the cell -- spelled "ED" is the confirm. return { -- upper = { -- { "A", "B", "C", "D", "E", "F", "G", "H", "I" }, -- { "J", "K", "L", "M", "N", "O", "P", "Q", "R" }, -- { "S", "T", "U", "V", "W", "X", "Y", "Z", " " }, -- { "-", "?", "!", "/", ".", ",", "", "", "ED" }, -- { "lower case" }, -- }, -- lower = { ... }, } ''' def _read_existing_catalog(path): """Pull the filled-in values out of a catalog we wrote earlier, so a refresh keeps the work. Deliberately a line scan rather than a Lua parse: it has to survive a half-edited file.""" done = {} if not os.path.isfile(path): return done entry = re.compile(r'^\s*\[(.+?)\]\s*=\s*("(?:[^"\\]|\\.)*")\s*,') with open(path, encoding="utf-8") as handle: for line in handle: match = entry.match(line) if match and match.group(2) != '""': done[match.group(1)] = match.group(2) return done def _merge_catalog(body, done): """Re-apply saved translations to a freshly generated catalog, and park anything whose key the engine no longer has in an ORPHANED block rather than dropping the work on the floor.""" used = set() out = [] entry = re.compile(r'^(\s*\[)(.+?)(\]\s*=\s*)""(,.*)$') for line in body.splitlines(): match = entry.match(line) if match and match.group(2) in done: key = match.group(2) used.add(key) line = f"{match.group(1)}{key}{match.group(3)}{done[key]}{match.group(4)}" out.append(line) orphans = [k for k in done if k not in used] if orphans: out += ["", "-- ORPHANED: these keys are no longer in the engine or the", "-- dataset, most likely because the English changed. Move the", "-- translation onto the new key above and delete the entry."] out.append("-- {") for key in sorted(orphans): out.append(f"-- [{key}] = {done[key]},") out.append("-- }") return "\n".join(out) + "\n" # ---------------------------------------------------------------- docs def cmd_docs(args, repo): """Regenerates the registry reference by driving the Schemas-backed generator, so the docs cannot drift from the engine.""" proc = subprocess.run( [LUAJIT, os.path.join("tools", "gen_registry_docs.lua")], cwd=repo) if proc.returncode != 0: return 1 generated = os.path.join(repo, "docs", "modding", "reference", "registries.md") if args.out: os.makedirs(args.out, exist_ok=True) target = os.path.join(args.out, "registries.md") with open(generated, encoding="utf-8") as src_handle, \ open(target, "w", encoding="utf-8") as dst_handle: dst_handle.write(src_handle.read()) if not args.quiet: print(f"copied to {target}") return 0 # ---------------------------------------------------------------- main def main(argv): # global flags ride a parent parser so they work on either side of the # subcommand (modkit --json validate x / modkit validate x --json); # SUPPRESS keeps the subparser pass from clobbering a value the main # parser already set (set_defaults would write the fallback back onto # the shared actions and re-clobber, so absentees are filled post-parse) shared = argparse.ArgumentParser(add_help=False) shared.add_argument("--repo", default=argparse.SUPPRESS, help="repo root override") shared.add_argument("--json", action="store_true", default=argparse.SUPPRESS) shared.add_argument("--quiet", action="store_true", default=argparse.SUPPRESS) parser = argparse.ArgumentParser(prog="modkit", parents=[shared]) sub = parser.add_subparsers(dest="command") p = sub.add_parser("scaffold", parents=[shared]) p.add_argument("id") p.add_argument("--profile", default="content", choices=["content", "overhaul", "total_conversion"]) p.add_argument("--api", type=int, default=2) p.add_argument("--github", default="", help="optional owner/repo (enables launcher auto-update)") p.add_argument("--experimental", action="store_true", help="mark the mod experimental (off until confirmed)") p.add_argument("--dest") p.add_argument("--force", action="store_true") p = sub.add_parser("validate", parents=[shared]) p.add_argument("mod") p.add_argument("--strict", action="store_true") p.add_argument("--base", default="auto", choices=["auto", "fixture", "imported"]) p = sub.add_parser("lint", parents=[shared]) p.add_argument("mod") p = sub.add_parser("pack", parents=[shared]) p.add_argument("mod") p.add_argument("-o", "--output") p.add_argument("--base", default="auto", choices=["auto", "fixture", "imported"]) p = sub.add_parser("bounce", parents=[shared]) p.add_argument("song", nargs="?") p.add_argument("--all", action="store_true") p.add_argument("--seconds", type=int, default=10) p.add_argument("--out") p = sub.add_parser("translation", parents=[shared]) p.add_argument("id") p.add_argument("--language", help="display name, e.g. \"Francais\"") p.add_argument("--dest") p.add_argument("--base", default="auto", choices=["auto", "fixture", "imported"]) p.add_argument("--refresh", action="store_true", help="re-harvest the catalogs, keeping existing work") p.add_argument("--force", action="store_true") p = sub.add_parser("docs", parents=[shared]) p.add_argument("--out") p = sub.add_parser("set-github", parents=[shared], help="add github field to an existing mod manifest") p.add_argument("mod") p.add_argument("url", help="owner/repo or https://github.com/owner/repo") p = sub.add_parser("add-release-workflow", parents=[shared], help="copy GitHub Actions release.yml into the mod") p.add_argument("mod") p.add_argument("--force", action="store_true") args = parser.parse_args(argv) for dest, fallback in (("repo", None), ("json", False), ("quiet", False)): if not hasattr(args, dest): setattr(args, dest, fallback) if not args.command: parser.print_help() return 2 if args.command == "bounce" and not (args.song or args.all): print("modkit: bounce needs a song id or --all") return 2 repo = args.repo or find_repo(os.getcwd()) or find_repo( os.path.dirname(os.path.abspath(__file__))) if not repo: print("modkit: cannot find the repo root " "(looked for tools/rom_manifest.json)") return 2 repo = os.path.abspath(repo) handler = { "scaffold": cmd_scaffold, "validate": cmd_validate, "lint": cmd_lint, "pack": cmd_pack, "bounce": cmd_bounce, "translation": cmd_translation, "docs": cmd_docs, "set-github": cmd_set_github, "add-release-workflow": cmd_add_release_workflow, }[args.command] return handler(args, repo) if __name__ == "__main__": sys.exit(main(sys.argv[1:]))