Add debug disassemble support

ober

0617e2cce737e458279d793fba6426f262653dae

diff --git a/Makefile b/Makefile
index f89065d..59591d8 100644
--- a/Makefile
+++ b/Makefile
@@ -509,6 +509,7 @@ test-phase5c:
 	@$(SCHEME) --libdirs $(LIBDIRS) --script tests/test-inspector.ss
 	@$(SCHEME) --libdirs $(LIBDIRS) --script tests/test-closure-inspect.ss
 	@$(SCHEME) --libdirs $(LIBDIRS) --script tests/test-record-inspect.ss
+	@$(SCHEME) --libdirs $(LIBDIRS) --script tests/test-disassemble.ss
 
 test-phase5d:
 	@echo "--- Phase 5d: Advanced Effects and Concurrency ---"
diff --git a/docs/disassembly.md b/docs/disassembly.md
index 08ce432..1124094 100644
--- a/docs/disassembly.md
+++ b/docs/disassembly.md
@@ -235,20 +235,42 @@ Because of this, platform tools such as `otool`, `objdump`, or
 
 ### Phase 1: Metadata
 
-Add a module such as:
+Status: implemented in `lib/std/debug/disassemble.ss`.
+
+The module is:
 
 ```scheme
 (std debug disassemble)
 ```
 
-Initial exports:
+Current exports:
 
 ```scheme
 (procedure-code-info proc)
 (write-procedure-code-info proc port)
+(hex-string->bytevector str)
+(disassemble-bytevector arch bv [options])
+(disassemble-bytevector->string arch bv [options])
+(disassembly-instruction? value)
+(disassembly-instruction-arch insn)
+(disassembly-instruction-address insn)
+(disassembly-instruction-size insn)
+(disassembly-instruction-text insn)
+(disassembly-instruction-bytes insn)
+(procedure-code-bytes proc)
+(procedure-disassembly proc)
+(disassemble proc)
+(disassemble proc port)
 ```
 
-This phase should be implemented using only public Chez hooks:
+`procedure-code-info` returns an alist with procedure name, source metadata,
+realm, arity mask, free-variable count, native entry address when available,
+byte count, machine type, Scheme version, `bytes-available?`, and status.
+`procedure-code-bytes` returns the exact native code bytes as a bytevector on
+supported native Chez targets. `disassemble` prints metadata followed by the
+current seed assembly decode.
+
+This phase is implemented using only public Chez hooks:
 
 - `inspect/object`
 - code object methods from the inspector
@@ -256,45 +278,70 @@ This phase should be implemented using only public Chez hooks:
 - `machine-type`
 - `scheme-version`
 
-This gives immediate value without depending on private object layout.
+This gives immediate metadata value without depending on private object layout.
+
+### Phase 1b: Bytevector Decoder
+
+Status: started in `lib/std/debug/disassemble.ss`.
+
+Jerboa can now disassemble caller-provided bytevectors without reading live
+procedure memory:
+
+```scheme
+(disassemble-bytevector->string
+  'x86-64
+  (hex-string->bytevector "55 48 89 e5 c3")
+  '((syntax . intel)))
+```
+
+The current decoder slice supports:
+
+- `x86-64`: seed register-only decode for common prologue, return, no-op,
+  immediate arithmetic, short conditional branch, call, and jump instructions.
+- `aarch64`: seed decode for return, move-immediate, add/sub immediate, and
+  simple load/store forms.
+
+This layer is also used by `procedure-disassembly` after Phase 2 extracts live
+procedure bytes.
 
 ### Phase 2: Byte Extraction
 
-Add a debug-only bridge that can return the exact native instruction bytevector
-for a code object.
+Status: implemented for native Chez code objects in `lib/std/debug/disassemble.ss`.
 
-The bridge needs:
+`procedure-code-bytes` now returns the exact native instruction bytevector for a
+procedure's code object.
 
-- code object validation
-- code entry address
-- exact code length
-- byte copy from the code data region
-- clear failure on portable-bytecode targets or unsupported machine types
+The bridge:
 
-Possible approaches:
+- obtains the code object via the public inspector.
+- reads the exact code length from the Chez code object.
+- verifies that the private code data address matches
+  `foreign-callable-entry-point`.
+- copies only that exact byte range into a fresh bytevector.
+- bounds the maximum copied code size.
 
-1. Expose a narrow Chez-side wrapper around the same internals used by
-   `inspect.ss`.
-2. Add a small C helper built with access to Chez internal headers/macros.
-3. Keep the bridge behind a debug feature flag so release builds do not depend
-   on private runtime layout.
+The implementation intentionally does not expose arbitrary address reads. It
+uses private Chez primitives internally and keeps them behind this narrow API.
 
-The helper should return data, not formatted assembly:
+The helper returns bytes, not formatted assembly:
 
 ```scheme
-(procedure-code-bytes proc) ; => bytevector plus metadata
+(procedure-code-bytes proc) ; => bytevector
 ```
 
 Formatting should stay separate from extraction.
 
 ### Phase 3: Disassembly Formatting
 
-Once bytes are available, Jerboa can format them in one of three ways:
+Status: seed formatter implemented in `lib/std/debug/disassemble.ss`.
+
+Now that bytes are available, Jerboa formats them with the built-in seed decoder.
+Future expansion can still add one of these broader approaches:
 
 1. Shell out to `llvm-objdump` or another platform disassembler after wrapping
    bytes in a minimal object container.
 2. Use a native disassembly library through FFI.
-3. Implement a small decoder for selected architectures later.
+3. Continue expanding the pure Jerboa decoder for selected architectures.
 
 The first version should target the machines Jerboa already builds:
 
@@ -352,12 +399,9 @@ printer.
 
 ## Recommendation
 
-Do not patch vendored Chez first.
-
-Start with `(std debug disassemble)` as a Jerboa debug module that exposes
-metadata. Then add a narrow, well-tested debug bridge for byte extraction. Once
-the bridge can return exact bytes and metadata, build the user-facing
-`disassemble` printer on top of that.
+Keep the risky part small.
 
-This keeps the risky part small and makes the first useful version available
-without committing Jerboa to Chez private layout as a public API.
+`(std debug disassemble)` now owns the narrow byte bridge and the user-facing
+printer. The next work should expand the pure Jerboa decoder table and add
+cross-machine tests, while keeping private Chez layout access confined to
+`procedure-code-bytes`.
diff --git a/lib/std/debug/disassemble.ss b/lib/std/debug/disassemble.ss
new file mode 100644
index 0000000..30a6d6d
--- /dev/null
+++ b/lib/std/debug/disassemble.ss
@@ -0,0 +1,622 @@
+;;; (std debug disassemble) — Procedure code metadata and disassembly entry point
+;;;
+;;; This module starts with metadata only. Exact instruction bytes require a
+;;; narrow debug bridge because the public inspector does not expose code length.
+
+(library (std debug disassemble)
+  (export
+    supported-disassembly-architectures
+    normalize-disassembly-architecture
+    machine-type->disassembly-architecture
+    hex-string->bytevector
+    make-disassembly-instruction
+    disassembly-instruction?
+    disassembly-instruction-arch
+    disassembly-instruction-address
+    disassembly-instruction-size
+    disassembly-instruction-text
+    disassembly-instruction-bytes
+    format-disassembly-instruction
+    format-disassembly-instructions
+    disassemble-bytevector
+    disassemble-bytevector->string
+    procedure-code-info
+    write-procedure-code-info
+    procedure-code-bytes
+    procedure-disassembly
+    disassemble)
+
+  (import (rnrs)
+          (only (chezscheme)
+                inspect/object
+                foreign-callable-entry-point
+                machine-type
+                scheme-version
+                eval
+                read
+                open-input-string
+                interaction-environment) ; jerboa-security: suppress direct-chezscheme-import-user-code
+          (only (jerboa core) def)
+          (std format))
+
+  (def supported-disassembly-architectures '(x86-64 aarch64))
+
+  ;; Chez native code object layout offsets. These are private Chez internals,
+  ;; so every byte extraction verifies that the public entry point equals the
+  ;; private data address before copying.
+  (def code-length-disp 9)
+  (def code-data-disp 65)
+  (def max-procedure-code-byte-count (* 16 1024 1024))
+  (def private-read read)
+
+  (def (make-private-primitive source)
+    (eval (private-read (open-input-string source)) ; jerboa-security: suppress missing-eintr-retry
+          (interaction-environment)))
+
+  (def private-object-ref-iptr
+    (make-private-primitive
+      "(lambda (obj offset) (#3%$object-ref 'iptr obj offset))"))
+
+  (def private-object-ref-u8
+    (make-private-primitive
+      "(lambda (obj offset) (#3%$object-ref 'unsigned-8 obj offset))"))
+
+  (def private-object-address
+    (make-private-primitive
+      "(lambda (obj offset) (#3%$object-address obj offset))"))
+
+  (def architecture-aliases
+    '((x86-64 . x86-64)
+      (x86_64 . x86-64)
+      (amd64 . x86-64)
+      (aarch64 . aarch64)
+      (arm64 . aarch64)))
+
+  (def (normalize-disassembly-architecture arch)
+    (let* ((sym (cond
+                  ((symbol? arch) arch)
+                  ((string? arch) (string->symbol arch))
+                  (#t (error 'normalize-disassembly-architecture
+                             "architecture must be a symbol or string"
+                             arch))))
+           (cell (assq sym architecture-aliases)))
+      (if cell
+          (cdr cell)
+          (error 'normalize-disassembly-architecture
+                 "unsupported architecture"
+                 arch))))
+
+  (def (machine-type->disassembly-architecture machine)
+    (let ((sym (if (symbol? machine) machine (string->symbol machine))))
+      (cond
+        ((or (eq? sym 'ta6le) (eq? sym 'ta6osx) (eq? sym 'ta6fb)) 'x86-64)
+        ((or (eq? sym 'tarm64le) (eq? sym 'tarm64osx) (eq? sym 'tarm64fb)) 'aarch64)
+        (#t #f))))
+
+  (def (make-disassembly-instruction arch address size text bytes)
+    (vector 'disassembly-instruction arch address size text bytes))
+
+  (def (disassembly-instruction? x)
+    (and (vector? x)
+         (= (vector-length x) 6)
+         (eq? (vector-ref x 0) 'disassembly-instruction)))
+
+  (def (disassembly-instruction-arch insn) (vector-ref insn 1))
+  (def (disassembly-instruction-address insn) (vector-ref insn 2))
+  (def (disassembly-instruction-size insn) (vector-ref insn 3))
+  (def (disassembly-instruction-text insn) (vector-ref insn 4))
+  (def (disassembly-instruction-bytes insn) (vector-ref insn 5))
+
+  (def (option-ref options key default)
+    (let ((cell (assq key options)))
+      (if cell (cdr cell) default)))
+
+  (def (hex-digit c)
+    (cond
+      ((and (char>=? c #\0) (char<=? c #\9))
+       (- (char->integer c) (char->integer #\0)))
+      ((and (char>=? c #\a) (char<=? c #\f))
+       (+ 10 (- (char->integer c) (char->integer #\a))))
+      ((and (char>=? c #\A) (char<=? c #\F))
+       (+ 10 (- (char->integer c) (char->integer #\A))))
+      (#t #f)))
+
+  (def (hex-skip? c)
+    (or (char-whitespace? c)
+        (char=? c #\,)
+        (char=? c #\:)
+        (char=? c #\_)))
+
+  (def (clean-hex-string s)
+    (let ((n (string-length s)))
+      (let loop ((i 0) (out '()))
+        (cond
+          ((= i n) (list->string (reverse out)))
+          ((and (< (+ i 1) n)
+                (char=? (string-ref s i) #\0)
+                (or (char=? (string-ref s (+ i 1)) #\x)
+                    (char=? (string-ref s (+ i 1)) #\X)))
+           (loop (+ i 2) out))
+          ((hex-skip? (string-ref s i))
+           (loop (+ i 1) out))
+          (#t
+           (loop (+ i 1) (cons (string-ref s i) out)))))))
+
+  (def (hex-string->bytevector s)
+    (let* ((clean (clean-hex-string s))
+           (n (string-length clean)))
+      (when (or (= n 0) (not (= (mod n 2) 0)))
+        (error 'hex-string->bytevector
+               "hex string must contain a non-empty even number of digits"
+               s))
+      (let ((bv (make-bytevector (div n 2) 0)))
+        (let loop ((i 0) (j 0))
+          (if (= i n)
+              bv
+              (let ((hi (hex-digit (string-ref clean i)))
+                    (lo (hex-digit (string-ref clean (+ i 1)))))
+                (unless (and hi lo)
+                  (error 'hex-string->bytevector "invalid hex digit" s))
+                (bytevector-u8-set! bv j (+ (* hi 16) lo))
+                (loop (+ i 2) (+ j 1))))))))
+
+  (def (bv-slice bv start slice-size)
+    (let ((out (make-bytevector slice-size 0)))
+      (bytevector-copy! bv start out 0 slice-size)
+      out))
+
+  (def (byte->hex2 b)
+    (let ((digits "0123456789abcdef"))
+      (string (string-ref digits (div b 16))
+              (string-ref digits (mod b 16)))))
+
+  (def (ascii-downcase c)
+    (if (and (char>=? c #\A) (char<=? c #\Z))
+        (integer->char (+ (char->integer #\a)
+                          (- (char->integer c) (char->integer #\A))))
+        c))
+
+  (def (number->hex n)
+    (list->string (map ascii-downcase
+                       (string->list (number->string n 16)))))
+
+  (def (hex-number n)
+    (string-append "0x" (number->hex n)))
+
+  (def (signed8 b)
+    (if (>= b 128) (- b 256) b))
+
+  (def (signed32/u32 x)
+    (if (>= x #x80000000) (- x #x100000000) x))
+
+  (def (bv-u8 bv offset)
+    (bytevector-u8-ref bv offset))
+
+  (def (bv-u32-le bv offset)
+    (+ (bv-u8 bv offset)
+       (* (bv-u8 bv (+ offset 1)) #x100)
+       (* (bv-u8 bv (+ offset 2)) #x10000)
+       (* (bv-u8 bv (+ offset 3)) #x1000000)))
+
+  (def (bits n hi lo)
+    (mod (div n (expt 2 lo)) (expt 2 (+ 1 (- hi lo)))))
+
+  (def x86-reg64
+    '#("rax" "rcx" "rdx" "rbx" "rsp" "rbp" "rsi" "rdi"
+       "r8" "r9" "r10" "r11" "r12" "r13" "r14" "r15"))
+
+  (def x86-reg32
+    '#("eax" "ecx" "edx" "ebx" "esp" "ebp" "esi" "edi"
+       "r8d" "r9d" "r10d" "r11d" "r12d" "r13d" "r14d" "r15d"))
+
+  (def (x86-reg n width)
+    (vector-ref (if (= width 64) x86-reg64 x86-reg32) n))
+
+  (def (x86-att-reg name)
+    (string-append "%" name))
+
+  (def (x86-intel-binop mnemonic dst src)
+    (string-append mnemonic "\t" dst ", " src))
+
+  (def (x86-att-binop mnemonic suffix dst src)
+    (string-append mnemonic suffix "\t" (x86-att-reg src) ", " (x86-att-reg dst)))
+
+  (def (x86-format-binop mnemonic suffix syntax dst src)
+    (if (eq? syntax 'att)
+        (x86-att-binop mnemonic suffix dst src)
+        (x86-intel-binop mnemonic dst src)))
+
+  (def (x86-format-immop mnemonic suffix syntax dst imm)
+    (let ((imm-text (if (>= imm 10) (hex-number imm) (number->string imm))))
+      (if (eq? syntax 'att)
+          (string-append mnemonic suffix "\t$" imm-text ", " (x86-att-reg dst))
+          (string-append mnemonic "\t" dst ", " imm-text))))
+
+  (def (x86-unknown bv offset address)
+    (make-disassembly-instruction 'x86-64 address 1
+      (string-append ".byte\t0x" (byte->hex2 (bv-u8 bv offset)))
+      (bv-slice bv offset 1)))
+
+  (def (decode-x86-64 bv offset address options)
+    (let* ((byte-count (bytevector-length bv))
+           (syntax (option-ref options 'syntax 'att)))
+      (if (>= offset byte-count)
+          #f
+          (let* ((b0 (bv-u8 bv offset))
+                 (has-rex? (and (>= b0 #x40) (<= b0 #x4f)))
+                 (rex (if has-rex? b0 0))
+                 (rex-w? (not (= (mod (div rex 8) 2) 0)))
+                 (rex-r (if (= (mod (div rex 4) 2) 0) 0 8))
+                 (rex-b (if (= (mod rex 2) 0) 0 8))
+                 (opoff (if has-rex? (+ offset 1) offset)))
+            (if (>= opoff byte-count)
+                (x86-unknown bv offset address)
+                (let ((op (bv-u8 bv opoff)))
+                  (cond
+                    ((and (>= op #x50) (<= op #x57))
+                     (let* ((reg (x86-reg (+ (- op #x50) rex-b) 64))
+                            (text (if (eq? syntax 'att)
+                                      (string-append "pushq\t" (x86-att-reg reg))
+                                      (string-append "push\t" reg)))
+                            (size (+ 1 (if has-rex? 1 0))))
+                       (make-disassembly-instruction 'x86-64 address size text
+                         (bv-slice bv offset size))))
+                    ((= op #xc3)
+                     (let ((size (+ 1 (if has-rex? 1 0))))
+                       (make-disassembly-instruction 'x86-64 address size
+                         (if (eq? syntax 'att) "retq" "ret")
+                         (bv-slice bv offset size))))
+                    ((= op #x90)
+                     (let ((size (+ 1 (if has-rex? 1 0))))
+                       (make-disassembly-instruction 'x86-64 address size "nop"
+                         (bv-slice bv offset size))))
+                    ((and (or (= op #x89) (= op #x8b))
+                          (< (+ opoff 1) byte-count))
+                     (let* ((modrm (bv-u8 bv (+ opoff 1)))
+                            (mod-bits (div modrm 64))
+                            (reg (+ (mod (div modrm 8) 8) rex-r))
+                            (rm (+ (mod modrm 8) rex-b))
+                            (width (if rex-w? 64 32))
+                            (size (+ 2 (if has-rex? 1 0))))
+                       (if (= mod-bits 3)
+                           (let* ((reg-name (x86-reg reg width))
+                                  (rm-name (x86-reg rm width))
+                                  (dst (if (= op #x89) rm-name reg-name))
+                                  (src (if (= op #x89) reg-name rm-name))
+                                  (suffix (if (= width 64) "q" "l")))
+                             (make-disassembly-instruction 'x86-64 address size
+                               (x86-format-binop "mov" suffix syntax dst src)
+                               (bv-slice bv offset size)))
+                           (x86-unknown bv offset address))))
+                    ((and (= op #x83) (< (+ opoff 2) byte-count))
+                     (let* ((modrm (bv-u8 bv (+ opoff 1)))
+                            (imm (bv-u8 bv (+ opoff 2)))
+                            (mod-bits (div modrm 64))
+                            (subop (mod (div modrm 8) 8))
+                            (rm (+ (mod modrm 8) rex-b))
+                            (width (if rex-w? 64 32))
+                            (size (+ 3 (if has-rex? 1 0))))
+                       (if (and (= mod-bits 3) (or (= subop 0) (= subop 5)))
+                           (let ((mnemonic (if (= subop 0) "add" "sub"))
+                                 (suffix (if (= width 64) "q" "l")))
+                             (make-disassembly-instruction 'x86-64 address size
+                               (x86-format-immop mnemonic suffix syntax (x86-reg rm width) imm)
+                               (bv-slice bv offset size)))
+                           (x86-unknown bv offset address))))
+                    ((and (or (= op #xe8) (= op #xe9)) (< (+ opoff 4) byte-count))
+                     (let* ((rel (signed32/u32 (bv-u32-le bv (+ opoff 1))))
+                            (size (+ 5 (if has-rex? 1 0)))
+                            (target (+ address size rel))
+                            (mnemonic (if (= op #xe8) "call" "jmp")))
+                       (make-disassembly-instruction 'x86-64 address size
+                         (string-append mnemonic "\t" (hex-number target))
+                         (bv-slice bv offset size))))
+                    ((and (or (= op #x74) (= op #x75)) (< (+ opoff 1) byte-count))
+                     (let* ((rel (signed8 (bv-u8 bv (+ opoff 1))))
+                            (size (+ 2 (if has-rex? 1 0)))
+                            (target (+ address size rel))
+                            (mnemonic (if (= op #x74) "je" "jne")))
+                       (make-disassembly-instruction 'x86-64 address size
+                         (string-append mnemonic "\t" (hex-number target))
+                         (bv-slice bv offset size))))
+                    (#t (x86-unknown bv offset address)))))))))
+
+  (def (a64-reg prefix n sp?)
+    (cond
+      ((and sp? (= n 31)) "sp")
+      ((= n 31) (if (string=? prefix "x") "xzr" "wzr"))
+      (#t (string-append prefix (number->string n)))))
+
+  (def (a64-imm n)
+    (if (>= n 10)
+        (string-append "#0x" (number->hex n))
+        (string-append "#" (number->string n))))
+
+  (def (a64-unknown-word bv offset address remaining)
+    (if (< remaining 4)
+        (make-disassembly-instruction 'aarch64 address 1
+          (string-append ".byte\t0x" (byte->hex2 (bv-u8 bv offset)))
+          (bv-slice bv offset 1))
+        (let ((word (bv-u32-le bv offset)))
+          (make-disassembly-instruction 'aarch64 address 4
+            (string-append ".word\t0x" (number->hex word))
+            (bv-slice bv offset 4)))))
+
+  (def (decode-aarch64 bv offset address options)
+    (let* ((byte-count (bytevector-length bv))
+           (remaining (- byte-count offset)))
+      (if (< remaining 4)
+          (a64-unknown-word bv offset address remaining)
+          (let* ((word (bv-u32-le bv offset))
+                 (rd (bits word 4 0))
+                 (rn (bits word 9 5))
+                 (rt rd))
+            (cond
+              ((= word #xd65f03c0)
+               (make-disassembly-instruction 'aarch64 address 4 "ret" (bv-slice bv offset 4)))
+              ((= (bitwise-and word #xff800000) #xd2800000)
+               (let* ((imm16 (bits word 20 5))
+                      (hw (bits word 22 21))
+                      (imm (* imm16 (expt 2 (* 16 hw))))
+                      (reg (a64-reg "x" rd #f))
+                      (suffix (if (= hw 0) "" (string-append ", lsl #" (number->string (* 16 hw))))))
+                 (make-disassembly-instruction 'aarch64 address 4
+                   (string-append "mov\t" reg ", " (a64-imm imm) suffix)
+                   (bv-slice bv offset 4))))
+              ((= (bitwise-and word #xff800000) #x52800000)
+               (let* ((imm16 (bits word 20 5))
+                      (hw (bits word 22 21))
+                      (imm (* imm16 (expt 2 (* 16 hw))))
+                      (reg (a64-reg "w" rd #f))
+                      (suffix (if (= hw 0) "" (string-append ", lsl #" (number->string (* 16 hw))))))
+                 (make-disassembly-instruction 'aarch64 address 4
+                   (string-append "mov\t" reg ", " (a64-imm imm) suffix)
+                   (bv-slice bv offset 4))))
+              ((or (= (bitwise-and word #x7f000000) #x11000000)
+                   (= (bitwise-and word #x7f000000) #x51000000))
+               (let* ((sf? (not (= (bits word 31 31) 0)))
+                      (sub? (not (= (bits word 30 30) 0)))
+                      (shift? (not (= (bits word 22 22) 0)))
+                      (imm12 (bits word 21 10))
+                      (imm (if shift? (* imm12 4096) imm12))
+                      (prefix (if sf? "x" "w"))
+                      (mnemonic (if sub? "sub" "add")))
+                 (make-disassembly-instruction 'aarch64 address 4
+                   (string-append mnemonic "\t"
+                                  (a64-reg prefix rd #t) ", "
+                                  (a64-reg prefix rn #t) ", "
+                                  (a64-imm imm))
+                   (bv-slice bv offset 4))))
+              ((or (= (bitwise-and word #xffc00000) #xb9000000)
+                   (= (bitwise-and word #xffc00000) #xb9400000)
+                   (= (bitwise-and word #xffc00000) #xf9000000)
+                   (= (bitwise-and word #xffc00000) #xf9400000))
+               (let* ((load? (not (= (bits word 22 22) 0)))
+                      (x? (not (= (bits word 31 30) 2)))
+                      (prefix (if x? "x" "w"))
+                      (scale (if x? 8 4))
+                      (imm (* (bits word 21 10) scale))
+                      (mnemonic (if load? "ldr" "str")))
+                 (make-disassembly-instruction 'aarch64 address 4
+                   (string-append mnemonic "\t"
+                                  (a64-reg prefix rt #f) ", ["
+                                  (a64-reg "x" rn #t)
+                                  (if (= imm 0) "" (string-append ", " (a64-imm imm)))
+                                  "]")
+                   (bv-slice bv offset 4))))
+              (#t (a64-unknown-word bv offset address remaining)))))))
+
+  (def (decode-bytevector-instruction arch bv offset address options)
+    (let ((normalized (normalize-disassembly-architecture arch)))
+      (cond
+        ((eq? normalized 'x86-64) (decode-x86-64 bv offset address options))
+        ((eq? normalized 'aarch64) (decode-aarch64 bv offset address options))
+        (#t (error 'decode-bytevector-instruction
+                   "decoder not implemented for architecture"
+                   arch)))))
+
+  (def (format-disassembly-instruction insn)
+    (string-append
+      (hex-number (disassembly-instruction-address insn))
+      ":\t"
+      (disassembly-instruction-text insn)
+      "\n"))
+
+  (def (format-disassembly-instructions insns)
+    (apply string-append (map format-disassembly-instruction insns)))
+
+  (def (disassemble-bytevector arch bv . maybe-options)
+    (unless (bytevector? bv)
+      (error 'disassemble-bytevector "not a bytevector" bv))
+    (let* ((arch (normalize-disassembly-architecture arch))
+           (options (if (null? maybe-options) '() (car maybe-options)))
+           (base (option-ref options 'address 0))
+           (max-bytes (option-ref options 'max-bytes #f))
+           (stop-address (option-ref options 'stop-address #f))
+           (decode-limit (if max-bytes
+                             (min (bytevector-length bv) max-bytes)
+                             (bytevector-length bv))))
+      (when (and max-bytes (< max-bytes 0))
+        (error 'disassemble-bytevector "max-bytes must be non-negative" max-bytes))
+      (let loop ((offset 0) (address base) (out '()))
+        (if (or (>= offset decode-limit)
+                (and stop-address (>= address stop-address)))
+            (reverse out)
+            (let ((insn (decode-bytevector-instruction arch bv offset address options)))
+              (when (<= (disassembly-instruction-size insn) 0)
+                (error 'disassemble-bytevector
+                       "decoder returned a zero-sized instruction"
+                       insn))
+              (loop (+ offset (disassembly-instruction-size insn))
+                    (+ address (disassembly-instruction-size insn))
+                    (cons insn out)))))))
+
+  (def (disassemble-bytevector->string arch bv . maybe-options)
+    (let ((options (if (null? maybe-options) '() (car maybe-options))))
+      (format-disassembly-instructions (disassemble-bytevector arch bv options))))
+
+  (def (source-values code-object)
+    (guard (e (#t '()))
+      (call-with-values
+        (lambda () (code-object 'source-path))
+        list)))
+
+  (def (procedure->code-object proc who)
+    (unless (procedure? proc)
+      (error who "not a procedure" proc))
+    (let* ((procedure-object (inspect/object proc))
+           (code-object (procedure-object 'code)))
+      (code-object 'value)))
+
+  (def (code-object-byte-count code)
+    (private-object-ref-iptr code code-length-disp))
+
+  (def (safe-code-object-byte-count code)
+    (guard (e (#t #f))
+      (code-object-byte-count code)))
+
+  (def (code-object-data-address code)
+    (private-object-address code code-data-disp))
+
+  (def (validate-code-object-layout code who)
+    (let ((entry-address (foreign-callable-entry-point code))
+          (data-address (code-object-data-address code)))
+      (unless (and (integer? entry-address)
+                   (integer? data-address)
+                   (= entry-address data-address))
+        (error who
+               "code object data address does not match public entry point"
+               entry-address
+               data-address))))
+
+  (def (copy-code-object-bytes code who)
+    (validate-code-object-layout code who)
+    (let ((byte-count (code-object-byte-count code)))
+      (unless (and (integer? byte-count)
+                   (>= byte-count 0)
+                   (<= byte-count max-procedure-code-byte-count))
+        (error who "invalid code byte count" byte-count))
+      (let ((out (make-bytevector byte-count 0)))
+        (let loop ((i 0))
+          (if (= i byte-count)
+              out
+              (begin
+                (bytevector-u8-set! out i
+                  (private-object-ref-u8 code (+ code-data-disp i)))
+                (loop (+ i 1))))))))
+
+  (def (safe-code-value code-object key default)
+    (guard (e (#t default))
+      (code-object key)))
+
+  (def (safe-entry-address code)
+    (guard (e (#t #f))
+      (foreign-callable-entry-point code)))
+
+  (def (procedure-code-info proc)
+    (unless (procedure? proc)
+      (error 'procedure-code-info "not a procedure" proc))
+    (let* ((procedure-object (inspect/object proc))
+           (code-object (procedure-object 'code))
+           (code (code-object 'value))
+           (byte-count (safe-code-object-byte-count code))
+           (bytes-available? (if byte-count #t #f)))
+      `((kind . procedure-code-info)
+        (name . ,(safe-code-value code-object 'name #f))
+        (source . ,(source-values code-object))
+        (realm . ,(safe-code-value code-object 'realm #f))
+        (arity-mask . ,(safe-code-value code-object 'arity-mask #f))
+        (free-count . ,(safe-code-value code-object 'free-count #f))
+        (entry-address . ,(safe-entry-address code))
+        (byte-count . ,byte-count)
+        (machine-type . ,(machine-type))
+        (scheme-version . ,(scheme-version))
+        (bytes-available? . ,bytes-available?)
+        (status . ,(if bytes-available? 'bytes-available 'metadata-only)))))
+
+  (def (alist-value key alist default)
+    (let ((cell (assq key alist)))
+      (if cell (cdr cell) default)))
+
+  (def (address->string address)
+    (if (and address (integer? address))
+        (string-append "0x" (number->string address 16))
+        "unavailable"))
+
+  (def (source->string source)
+    (cond
+      ((null? source) "unknown")
+      ((and (= (length source) 3)
+            (car source)
+            (cadr source)
+            (caddr source))
+       (format "~a:~a:~a" (car source) (cadr source) (caddr source)))
+      ((and (= (length source) 2)
+            (car source)
+            (cadr source))
+       (format "~a:~a" (car source) (cadr source)))
+      (#t (format "~s" source))))
+
+  (def (write-info-line label value port)
+    (display label port)
+    (display ": " port)
+    (display value port)
+    (newline port))
+
+  (def (write-procedure-code-info proc port)
+    (let ((info (procedure-code-info proc)))
+      (write-info-line "procedure" (alist-value 'name info "unknown") port)
+      (write-info-line "source" (source->string (alist-value 'source info '())) port)
+      (write-info-line "machine" (alist-value 'machine-type info "unknown") port)
+      (write-info-line "scheme" (alist-value 'scheme-version info "unknown") port)
+      (write-info-line "entry" (address->string (alist-value 'entry-address info #f)) port)
+      (write-info-line "byte-count" (alist-value 'byte-count info "unknown") port)
+      (write-info-line "arity-mask" (alist-value 'arity-mask info "unknown") port)
+      (write-info-line "free-count" (alist-value 'free-count info "unknown") port)
+      (write-info-line "status" (alist-value 'status info "unknown") port)
+      info))
+
+  (def (procedure-code-bytes proc)
+    (copy-code-object-bytes
+      (procedure->code-object proc 'procedure-code-bytes)
+      'procedure-code-bytes))
+
+  (def procedure-disassembly
+    (case-lambda
+      ((proc) (procedure-disassembly proc '()))
+      ((proc options)
+       (let* ((info (procedure-code-info proc))
+              (bytes (procedure-code-bytes proc))
+              (arch (machine-type->disassembly-architecture (alist-value 'machine-type info #f)))
+              (instructions (if arch (disassemble-bytevector arch bytes options) '())))
+         `((info . ,info)
+           (bytes . ,bytes)
+           (instructions . ,instructions)
+           (status . ,(if arch 'decoded 'bytes-only)))))))
+
+  (def (write-disassembly-instructions instructions port)
+    (if (null? instructions)
+        (begin
+          (display "assembly: unavailable for this machine type" port)
+          (newline port))
+        (begin
+          (display "assembly:" port)
+          (newline port)
+          (for-each
+            (lambda (insn)
+              (display (format-disassembly-instruction insn) port))
+            instructions))))
+
+  (def disassemble
+    (case-lambda
+      ((proc) (disassemble proc (current-output-port)))
+      ((proc port)
+       (let ((report (procedure-disassembly proc)))
+         (write-procedure-code-info proc port)
+         (newline port)
+         (write-disassembly-instructions
+           (alist-value 'instructions report '())
+           port))
+       #t)))
+
+  )
diff --git a/libraries.md b/libraries.md
index 4b6e2cb..8b18f5c 100644
--- a/libraries.md
+++ b/libraries.md
@@ -164,6 +164,7 @@ Gerbil has ~438 `:std/*` modules. Jerboa currently implements 51. This document 
 | `(std mime types)` | MIME type database | TODO |
 | `(std io)` | Buffered I/O framework | TODO |
 | `(std debug DBG)` | Debug printing | TODO |
+| `(std debug disassemble)` | Procedure code metadata and bytevector disassembly entry point | Metadata + seed bytevector decoder implemented |
 | `(std debug heap)` | Heap analysis | TODO |
 | `(std debug threads)` | Thread debugging | TODO |
 | `(std web fastcgi)` | FastCGI protocol | TODO |
diff --git a/tests/test-disassemble.ss b/tests/test-disassemble.ss
new file mode 100644
index 0000000..a035633
--- /dev/null
+++ b/tests/test-disassemble.ss
@@ -0,0 +1,124 @@
+;;; Tests for (std debug disassemble)
+
+(import (jerboa prelude)
+        (std debug disassemble))
+
+(def pass-count 0)
+(def fail-count 0)
+
+(def (check name result expected)
+  (if (equal? result expected)
+      (begin
+        (set! pass-count (+ pass-count 1))
+        (display "  PASS: ")
+        (display name)
+        (newline))
+      (begin
+        (set! fail-count (+ fail-count 1))
+        (display "  FAIL: ")
+        (display name)
+        (newline)
+        (display "    expected: ")
+        (write expected)
+        (newline)
+        (display "    got:      ")
+        (write result)
+        (newline))))
+
+(def (check-true name result)
+  (check name (if result #t #f) #t))
+
+(def (alist-value key alist default)
+  (let ((cell (assq key alist)))
+    (if cell (cdr cell) default)))
+
+(def (contains? text needle)
+  (if (string-contains text needle) #t #f))
+
+(def (sample-procedure x)
+  (+ x 1))
+
+(displayln "--- (std debug disassemble) tests ---")
+
+(check "normalize x86 alias"
+       (normalize-disassembly-architecture 'amd64)
+       'x86-64)
+(check "normalize arm alias"
+       (normalize-disassembly-architecture "arm64")
+       'aarch64)
+(check-true "current machine maps to an architecture"
+            (machine-type->disassembly-architecture (machine-type)))
+
+(let ((bv (hex-string->bytevector "55 48 89 e5 c3")))
+  (check "hex bytevector length" (bytevector-length bv) 5)
+  (check "hex first byte" (bytevector-u8-ref bv 0) #x55)
+  (check "hex last byte" (bytevector-u8-ref bv 4) #xc3))
+
+(let* ((bv (hex-string->bytevector "55 48 89 e5 c3"))
+       (insns (disassemble-bytevector 'x86-64 bv '((syntax . intel)))))
+  (check "x86 instruction count" (length insns) 3)
+  (check-true "x86 instruction record" (disassembly-instruction? (car insns)))
+  (check "x86 first text" (disassembly-instruction-text (car insns)) "push\trbp")
+  (check "x86 second address" (disassembly-instruction-address (cadr insns)) 1)
+  (check "x86 second text" (disassembly-instruction-text (cadr insns)) "mov\trbp, rsp")
+  (check "x86 string"
+         (disassemble-bytevector->string 'x86-64 bv '((syntax . intel)))
+         "0x0:\tpush\trbp\n0x1:\tmov\trbp, rsp\n0x4:\tret\n"))
+
+(let ((bv (hex-string->bytevector "55 48 89 e5 c3")))
+  (check "x86 att string"
+         (disassemble-bytevector->string 'x86-64 bv)
+         "0x0:\tpushq\t%rbp\n0x1:\tmovq\t%rsp, %rbp\n0x4:\tretq\n"))
+
+(let ((bv (hex-string->bytevector "20 00 80 d2 c0 03 5f d6")))
+  (check "aarch64 string"
+         (disassemble-bytevector->string 'aarch64 bv)
+         "0x0:\tmov\tx0, #1\n0x4:\tret\n"))
+
+(let ((info (procedure-code-info sample-procedure)))
+  (check "info kind" (alist-value 'kind info #f) 'procedure-code-info)
+  (check "status has bytes" (alist-value 'status info #f) 'bytes-available)
+  (check "bytes are available" (alist-value 'bytes-available? info #f) #t)
+  (check-true "name is available" (alist-value 'name info #f))
+  (check-true "machine type is symbol" (symbol? (alist-value 'machine-type info #f)))
+  (check-true "scheme version is string" (string? (alist-value 'scheme-version info #f)))
+  (check-true "arity mask is available" (integer? (alist-value 'arity-mask info #f)))
+  (check-true "free count is available" (integer? (alist-value 'free-count info #f)))
+  (check-true "byte count is available" (integer? (alist-value 'byte-count info #f))))
+
+(let ((bytes (procedure-code-bytes sample-procedure)))
+  (check-true "procedure-code-bytes returns bytevector" (bytevector? bytes))
+  (check-true "procedure-code-bytes non-empty" (> (bytevector-length bytes) 0)))
+
+(let ((report (procedure-disassembly sample-procedure)))
+  (check "procedure-disassembly status"
+         (alist-value 'status report #f)
+         'decoded)
+  (check-true "procedure-disassembly bytes"
+              (bytevector? (alist-value 'bytes report #f)))
+  (check-true "procedure-disassembly instructions"
+              (list? (alist-value 'instructions report #f))))
+
+(let ((text (with-output-to-string
+              (lambda ()
+                (write-procedure-code-info sample-procedure
+                                           (current-output-port))))))
+  (check-true "printer includes procedure" (contains? text "procedure:"))
+  (check-true "printer includes machine" (contains? text "machine:"))
+  (check-true "printer includes status" (contains? text "status: bytes-available")))
+
+(let ((text (with-output-to-string
+              (lambda ()
+                (disassemble sample-procedure)))))
+  (check-true "disassemble includes metadata" (contains? text "procedure:"))
+  (check-true "disassemble includes assembly" (contains? text "assembly:")))
+
+(display "Disassemble tests: ")
+(display pass-count)
+(display " passed, ")
+(display fail-count)
+(display " failed")
+(newline)
+
+(when (> fail-count 0)
+  (exit 1))