Skip to content

Commit 2005f0f

Browse files
committed
feat: add BYOND .dmi/.dmm/.dmf project-file extractors
Beyond .dm source, a BYOND project ships three structured assets the graph should know about: - .dmi (icon sheets): PNG with a zTXt "Description" chunk holding BYOND state metadata. extract_dmi parses it with stdlib struct+zlib and emits one node per icon state name (the same names DM code references via `icon_state = "X"`). - .dmm (map files): tile dictionary entries name the types that compose each tile, e.g. `"a" = (/obj/structure/table{...}, /area/maintenance)`. extract_dmm parses the dictionary section (skipping the grid), splits tile bodies on top-level commas, strips `{var=val}` overrides, and emits `uses` edges from the map file to each referenced type. Format reference: SpacemanDMM/crates/dmm-tools/src/dmm/read.rs — tree-sitter-dm claims .dmm support but errors on the `"key" = (...)` syntax in practice, hence the custom parser. - .dmf (interface forms): hierarchical key-value text. extract_dmf emits window and elem nodes with the BYOND control type encoded in the elem label (e.g. `elem "map" [MAP]`), so winset/winget references in DM code have something to point at. All three are registered in _DISPATCH and CODE_EXTENSIONS. .dmi moves out of IMAGE_EXTENSIONS now that it has a real extractor — the metadata is more useful to a code graph than an LLM caption of the sprite sheet. Smoke-tested across the full spacestation13/dm-test-suite corpus: 0 extraction errors. 13 new tests for the three extractors.
1 parent ed9ca3d commit 2005f0f

6 files changed

Lines changed: 482 additions & 3 deletions

File tree

graphify/detect.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,10 @@ class FileType(str, Enum):
2424

2525
_MANIFEST_PATH = "graphify-out/manifest.json"
2626

27-
CODE_EXTENSIONS = {'.py', '.ts', '.js', '.jsx', '.tsx', '.mjs', '.ejs', '.go', '.rs', '.java', '.groovy', '.gradle', '.cpp', '.cc', '.cxx', '.c', '.h', '.hpp', '.rb', '.swift', '.kt', '.kts', '.cs', '.scala', '.php', '.lua', '.luau', '.toc', '.zig', '.ps1', '.ex', '.exs', '.m', '.mm', '.jl', '.vue', '.svelte', '.astro', '.dart', '.v', '.sv', '.sql', '.r', '.f', '.F', '.f90', '.F90', '.f95', '.F95', '.f03', '.F03', '.f08', '.F08', '.pas', '.pp', '.dpr', '.dpk', '.lpr', '.inc', '.dfm', '.lfm', '.lpk', '.sh', '.bash', '.json', '.dm', '.dme'}
27+
CODE_EXTENSIONS = {'.py', '.ts', '.js', '.jsx', '.tsx', '.mjs', '.ejs', '.go', '.rs', '.java', '.groovy', '.gradle', '.cpp', '.cc', '.cxx', '.c', '.h', '.hpp', '.rb', '.swift', '.kt', '.kts', '.cs', '.scala', '.php', '.lua', '.luau', '.toc', '.zig', '.ps1', '.ex', '.exs', '.m', '.mm', '.jl', '.vue', '.svelte', '.astro', '.dart', '.v', '.sv', '.sql', '.r', '.f', '.F', '.f90', '.F90', '.f95', '.F95', '.f03', '.F03', '.f08', '.F08', '.pas', '.pp', '.dpr', '.dpk', '.lpr', '.inc', '.dfm', '.lfm', '.lpk', '.sh', '.bash', '.json', '.dm', '.dme', '.dmi', '.dmm', '.dmf'}
2828
DOC_EXTENSIONS = {'.md', '.mdx', '.qmd', '.txt', '.rst', '.html', '.yaml', '.yml'}
2929
PAPER_EXTENSIONS = {'.pdf'}
30-
IMAGE_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.dmi'}
30+
IMAGE_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg'}
3131
OFFICE_EXTENSIONS = {'.docx', '.xlsx'}
3232
VIDEO_EXTENSIONS = {'.mp4', '.mov', '.webm', '.mkv', '.avi', '.m4v', '.mp3', '.wav', '.m4a', '.ogg'}
3333

graphify/extract.py

Lines changed: 307 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3164,6 +3164,310 @@ def walk_calls(body_node, caller_nid: str) -> None:
31643164
return {"nodes": nodes, "edges": edges, "raw_calls": raw_calls}
31653165

31663166

3167+
# ── DMI (BYOND icon files) ────────────────────────────────────────────────────
3168+
# .dmi is a PNG with a `zTXt`/`tEXt` "Description" chunk containing BYOND state
3169+
# metadata. We don't care about pixels — we want the icon state names, because
3170+
# `icon_state = "X"` in DM code references them.
3171+
3172+
def _read_dmi_description(data: bytes) -> str:
3173+
"""Pull the BYOND metadata text out of a .dmi PNG, or empty string on failure."""
3174+
import struct
3175+
import zlib
3176+
if not data.startswith(b"\x89PNG\r\n\x1a\n"):
3177+
return ""
3178+
i = 8
3179+
while i + 8 <= len(data):
3180+
length = struct.unpack(">I", data[i:i+4])[0]
3181+
chunk_type = data[i+4:i+8]
3182+
payload = data[i+8:i+8+length]
3183+
if chunk_type in (b"tEXt", b"zTXt"):
3184+
try:
3185+
null = payload.index(b"\x00")
3186+
except ValueError:
3187+
return ""
3188+
keyword = payload[:null]
3189+
if keyword == b"Description":
3190+
if chunk_type == b"zTXt":
3191+
# zTXt: keyword \0 method(1 byte) compressed-data
3192+
return zlib.decompress(payload[null+2:]).decode("utf-8", errors="replace")
3193+
return payload[null+1:].decode("utf-8", errors="replace")
3194+
i += 8 + length + 4 # length + type + payload + crc
3195+
return ""
3196+
3197+
3198+
def extract_dmi(path: Path) -> dict:
3199+
"""Extract icon state names from a .dmi (BYOND PNG icon sheet)."""
3200+
try:
3201+
data = path.read_bytes()
3202+
except Exception as e:
3203+
return {"nodes": [], "edges": [], "error": str(e)}
3204+
3205+
str_path = str(path)
3206+
stem = _file_stem(path)
3207+
file_nid = _make_id(str(path))
3208+
nodes: list[dict] = [{
3209+
"id": file_nid, "label": path.name, "file_type": "code",
3210+
"source_file": str_path, "source_location": "L1",
3211+
}]
3212+
edges: list[dict] = []
3213+
3214+
description = _read_dmi_description(data)
3215+
if not description:
3216+
return {"nodes": nodes, "edges": edges}
3217+
3218+
seen: set[str] = {file_nid}
3219+
line_no = 0
3220+
for raw_line in description.splitlines():
3221+
line_no += 1
3222+
stripped = raw_line.strip()
3223+
if not stripped.startswith("state ="):
3224+
continue
3225+
# state = "name"
3226+
value = stripped.split("=", 1)[1].strip()
3227+
if value.startswith('"') and value.endswith('"') and len(value) >= 2:
3228+
state_name = value[1:-1]
3229+
else:
3230+
state_name = value
3231+
if not state_name:
3232+
continue
3233+
nid = _make_id(stem, "state", state_name)
3234+
if nid in seen:
3235+
continue
3236+
seen.add(nid)
3237+
label = f'"{state_name}"'
3238+
nodes.append({
3239+
"id": nid, "label": label, "file_type": "code",
3240+
"source_file": str_path, "source_location": f"L{line_no}",
3241+
})
3242+
edges.append({
3243+
"source": file_nid, "target": nid, "relation": "contains",
3244+
"confidence": "EXTRACTED", "source_file": str_path,
3245+
"source_location": f"L{line_no}", "weight": 1.0,
3246+
})
3247+
3248+
return {"nodes": nodes, "edges": edges}
3249+
3250+
3251+
# ── DMM (BYOND map files) ─────────────────────────────────────────────────────
3252+
# A .dmm starts with a tile dictionary — each `"key" = (type, type{var=val}, ...)`
3253+
# names one or more types that compose a tile — then a grid laying tile keys in
3254+
# a 3D coordinate space. We only need the dictionary section: every type path
3255+
# referenced from a map is a `uses` edge from the map file to that type, which
3256+
# lets queries answer "where is /obj/foo placed?". Format reference:
3257+
# https://github.com/SpaceManiac/SpacemanDMM/blob/master/crates/dmm-tools/src/dmm/read.rs
3258+
3259+
# Grid-section header: "(x,y,z) = {" or TGM variant. The dictionary is everything
3260+
# before the first match.
3261+
_DMM_GRID_RE = re.compile(r"^\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*\)\s*=", re.MULTILINE)
3262+
3263+
3264+
def _split_dmm_tile(body: str) -> list[str]:
3265+
"""Split a tile's `(type1, type2{...}, type3)` body into raw entries at top-level commas."""
3266+
out: list[str] = []
3267+
buf: list[str] = []
3268+
depth = 0
3269+
in_string = False
3270+
escape = False
3271+
for ch in body:
3272+
if escape:
3273+
buf.append(ch)
3274+
escape = False
3275+
continue
3276+
if in_string:
3277+
buf.append(ch)
3278+
if ch == "\\":
3279+
escape = True
3280+
elif ch == '"':
3281+
in_string = False
3282+
continue
3283+
if ch == '"':
3284+
in_string = True
3285+
buf.append(ch)
3286+
elif ch in "({[":
3287+
depth += 1
3288+
buf.append(ch)
3289+
elif ch in ")}]":
3290+
depth -= 1
3291+
buf.append(ch)
3292+
elif ch == "," and depth == 0:
3293+
out.append("".join(buf).strip())
3294+
buf = []
3295+
else:
3296+
buf.append(ch)
3297+
tail = "".join(buf).strip()
3298+
if tail:
3299+
out.append(tail)
3300+
return out
3301+
3302+
3303+
def _dmm_type_path(entry: str) -> str:
3304+
"""Strip var overrides and trailing whitespace, leaving just `/foo/bar/baz`."""
3305+
brace = entry.find("{")
3306+
if brace != -1:
3307+
entry = entry[:brace]
3308+
return entry.strip()
3309+
3310+
3311+
def extract_dmm(path: Path) -> dict:
3312+
"""Extract type-path references from a .dmm map file's tile dictionary."""
3313+
try:
3314+
text = path.read_text(encoding="utf-8", errors="replace")
3315+
except Exception as e:
3316+
return {"nodes": [], "edges": [], "error": str(e)}
3317+
3318+
str_path = str(path)
3319+
stem = _file_stem(path)
3320+
file_nid = _make_id(str(path))
3321+
nodes: list[dict] = [{
3322+
"id": file_nid, "label": path.name, "file_type": "code",
3323+
"source_file": str_path, "source_location": "L1",
3324+
}]
3325+
edges: list[dict] = []
3326+
3327+
# Trim to the dictionary section (everything before the first grid header).
3328+
grid_match = _DMM_GRID_RE.search(text)
3329+
dict_text = text[:grid_match.start()] if grid_match else text
3330+
3331+
# Parse line-by-line, accumulating until we close the parens of each tile.
3332+
seen_targets: set[str] = set()
3333+
buf: list[str] = []
3334+
open_line = 0
3335+
depth = 0
3336+
in_string = False
3337+
escape = False
3338+
for line_idx, line in enumerate(dict_text.splitlines(), start=1):
3339+
for ch in line:
3340+
if escape:
3341+
escape = False
3342+
elif in_string:
3343+
if ch == "\\":
3344+
escape = True
3345+
elif ch == '"':
3346+
in_string = False
3347+
elif ch == '"':
3348+
in_string = True
3349+
elif ch == "(":
3350+
if depth == 0:
3351+
open_line = line_idx
3352+
depth += 1
3353+
elif ch == ")":
3354+
depth -= 1
3355+
buf.append(ch)
3356+
buf.append("\n")
3357+
if depth == 0 and buf:
3358+
chunk = "".join(buf)
3359+
buf = []
3360+
# Find the outermost (...) span in this completed chunk.
3361+
lp = chunk.find("(")
3362+
rp = chunk.rfind(")")
3363+
if lp == -1 or rp == -1 or rp <= lp:
3364+
continue
3365+
inner = chunk[lp+1:rp]
3366+
for entry in _split_dmm_tile(inner):
3367+
tpath = _dmm_type_path(entry)
3368+
if not tpath.startswith("/"):
3369+
continue
3370+
tgt = _make_id(tpath)
3371+
if tgt in seen_targets:
3372+
continue
3373+
seen_targets.add(tgt)
3374+
edges.append({
3375+
"source": file_nid, "target": tgt, "relation": "uses",
3376+
"context": "map", "confidence": "EXTRACTED",
3377+
"source_file": str_path,
3378+
"source_location": f"L{open_line}", "weight": 1.0,
3379+
})
3380+
3381+
return {"nodes": nodes, "edges": edges}
3382+
3383+
3384+
# ── DMF (BYOND interface forms) ───────────────────────────────────────────────
3385+
# Hierarchical key-value text:
3386+
# window "main"
3387+
# elem "map"
3388+
# type = MAP
3389+
# Indented blocks; control names live under `elem "X"`; the most useful thing
3390+
# to surface is the window/elem hierarchy because `winset()`/`winget()` calls
3391+
# in DM code reference these by name.
3392+
3393+
_DMF_WINDOW_RE = re.compile(r'^\s*window\s+"([^"]+)"\s*$')
3394+
_DMF_ELEM_RE = re.compile(r'^\s*elem\s+"([^"]+)"\s*$')
3395+
_DMF_TYPE_RE = re.compile(r'^\s*type\s*=\s*(\S+)\s*$')
3396+
3397+
3398+
def extract_dmf(path: Path) -> dict:
3399+
"""Extract windows and controls from a .dmf interface file."""
3400+
try:
3401+
text = path.read_text(encoding="utf-8", errors="replace")
3402+
except Exception as e:
3403+
return {"nodes": [], "edges": [], "error": str(e)}
3404+
3405+
str_path = str(path)
3406+
stem = _file_stem(path)
3407+
file_nid = _make_id(str(path))
3408+
nodes: list[dict] = [{
3409+
"id": file_nid, "label": path.name, "file_type": "code",
3410+
"source_file": str_path, "source_location": "L1",
3411+
}]
3412+
edges: list[dict] = []
3413+
seen: set[str] = {file_nid}
3414+
3415+
current_window_nid: str | None = None
3416+
current_elem_nid: str | None = None
3417+
current_elem_name: str | None = None
3418+
3419+
for line_idx, line in enumerate(text.splitlines(), start=1):
3420+
m = _DMF_WINDOW_RE.match(line)
3421+
if m:
3422+
name = m.group(1)
3423+
nid = _make_id(stem, "window", name)
3424+
if nid not in seen:
3425+
seen.add(nid)
3426+
nodes.append({
3427+
"id": nid, "label": f'window "{name}"', "file_type": "code",
3428+
"source_file": str_path, "source_location": f"L{line_idx}",
3429+
})
3430+
edges.append({
3431+
"source": file_nid, "target": nid, "relation": "contains",
3432+
"confidence": "EXTRACTED", "source_file": str_path,
3433+
"source_location": f"L{line_idx}", "weight": 1.0,
3434+
})
3435+
current_window_nid = nid
3436+
current_elem_nid = None
3437+
current_elem_name = None
3438+
continue
3439+
m = _DMF_ELEM_RE.match(line)
3440+
if m and current_window_nid is not None:
3441+
name = m.group(1)
3442+
nid = _make_id(stem, "elem", current_window_nid, name)
3443+
if nid not in seen:
3444+
seen.add(nid)
3445+
nodes.append({
3446+
"id": nid, "label": f'elem "{name}"', "file_type": "code",
3447+
"source_file": str_path, "source_location": f"L{line_idx}",
3448+
})
3449+
edges.append({
3450+
"source": current_window_nid, "target": nid,
3451+
"relation": "contains", "confidence": "EXTRACTED",
3452+
"source_file": str_path, "source_location": f"L{line_idx}",
3453+
"weight": 1.0,
3454+
})
3455+
current_elem_nid = nid
3456+
current_elem_name = name
3457+
continue
3458+
m = _DMF_TYPE_RE.match(line)
3459+
if m and current_elem_nid is not None and current_elem_name is not None:
3460+
# Encode the BYOND control type into the elem's label so it shows
3461+
# up in the graph (MAP, BUTTON, INPUT, …).
3462+
ctype = m.group(1)
3463+
for n in nodes:
3464+
if n["id"] == current_elem_nid and " [" not in n["label"]:
3465+
n["label"] = f'elem "{current_elem_name}" [{ctype}]'
3466+
break
3467+
3468+
return {"nodes": nodes, "edges": edges}
3469+
3470+
31673471
# ── Julia extractor (custom walk) ────────────────────────────────────────────
31683472

31693473
def extract_julia(path: Path) -> dict:
@@ -6335,6 +6639,9 @@ def walk_object(obj_node, parent_nid: str, parent_key: str | None,
63356639
".json": extract_json,
63366640
".dm": extract_dm,
63376641
".dme": extract_dm,
6642+
".dmi": extract_dmi,
6643+
".dmm": extract_dmm,
6644+
".dmf": extract_dmf,
63386645
}
63396646

63406647

0 commit comments

Comments
 (0)