Ketoy
Reference

`.ktx` Bundle Format

.ktx is the on-wire format for a KBC bundle. Brotli-compressed, Ed25519-signed, format version 2. This page documents every byte.

If you're consuming bundles, use the KtxReader class — don't parse by hand. This reference is for tool authors and protocol implementers.


Top-level layout

+------------------------+
| Header (14 bytes)      |
+------------------------+
| Section 1              |
+------------------------+
| Section 2              |
+------------------------+
| ...                    |
+------------------------+
| Section N              |
+------------------------+
| Signature (64 bytes)   |
+------------------------+

The signature trailer is always 64 bytes (Ed25519 signature length). For unsigned bundles the trailer is 64 zero bytes and the UNSIGNED flag is set in the header.


Header (14 bytes)

OffsetSizeFieldNotes
04magic0x4B 0x54 0x4F 0x59 ("KTOY")
42formatVersionu16 big-endian. 2 as of 0.3.4-alpha.
62minRuntimeVersionu16 — minimum KetoyVersion.CURRENT_RUNTIME_VERSION required.
84flagsu32 big-endian — see below.
122sectionCountu16 — number of sections in the body.

No padding bytes. The header is exactly 14 bytes.

Header flags

BitNameMeaning
0x01DEBUG_INFO_PRESENTWriter set debugMode = true.
0x02UNSIGNEDThe 64-byte trailer is zero — signature was not produced.

Other bits are reserved.


Section envelope (10 bytes per section)

+--------+--------+-----------------+-----------------+
| type:1 | comp:1 | uncompressed:4  | onDisk:4        |
+--------+--------+-----------------+-----------------+
| payload (onDiskSize bytes — Brotli-compressed if comp != 0)
+----------------------------------------------------+
FieldMeaning
type:u8One of the 10 section type bytes (0x010x0A).
compressed:u80 = raw, 1 = Brotli.
uncompressedSize:u32Logical payload size after decompression.
onDiskSize:u32Physical payload size on disk.

Only the CODE, MODIFIER_TABLE, and DEBUG_INFO sections are eligible for Brotli compression today. Others stay raw because they're small string tables and dense.


Section types (KtxSectionType)

IDSectionEmission orderRequired
0x01STRING_POOL1Yes
0x02ADAPTER_MANIFEST2Yes (may be empty)
0x03CONSTRUCTOR_MANIFEST3Yes (may be empty)
0x04CAPABILITY_MANIFEST4Yes (may be empty)
0x05MODIFIER_TABLE5Optional (omitted when empty)
0x06FUNCTION_TABLE6Yes
0x07CODE7Yes
0x08DEBUG_INFO8Optional
0x09ENTRY_POINTS9Yes (may be empty)
0x0ABUNDLE_METADATA10Yes (v2+)

KtxReader validates strict ordering — STRING_POOL must come first, others follow in the listed order. Unknown section types fail with KetoyBundleMalformedException.


Section payloads

STRING_POOL

count:i32
[length:u16 + utf8 bytes]*

Index 0 is the empty string. Pool entries are de-duplicated by the writer.

ADAPTER_MANIFEST / CONSTRUCTOR_MANIFEST / CAPABILITY_MANIFEST

count:u16
[id:i16 + nameIdx:u16 + required:u8]*

required is a forward-compat hint; today the runtime treats all manifest entries as strict (every declared ID must be registered).

MODIFIER_TABLE

count:u16
[length:u16 + opaque bytes]*

Each entry is an opaque KBCModifierDescriptor serialised by KBCModifierCodec. The runtime lazy-decodes on first access via the KBCModifierTable index. KBC source references modifier table entries via KBCValue.ModifierRef(tableIdx).

FUNCTION_TABLE

count:u16
[nameIdx:u16 + localCount:u8 + parameterCount:u8 + flags:u8 +
 contTableCount:u16 + [pc:i32 + dst:u8]*
 handlerCount:u16 + [start:i32 + end:i32 + handler:i32 + typeIdx:u16]*]*

Function flags:

BitName
0x01FN_FLAG_SUSPENDABLE
0x02FN_FLAG_COMPOSABLE

CODE

[fnIdx:u16 + bytesLength:i32 + bytes:u8*]*

Brotli-compressed in production builds. The reader decompresses section-as-whole before decoding individual function code blocks.

DEBUG_INFO

[fnIdx:u16 + lineCount:u16 + [pc:i32 + line:u16]*]*

Optional source-line mapping. Present only when debugMode = true.

ENTRY_POINTS

count:u16
[nameIdx:u16 + fnIdx:u32]*

Maps @KetoyEntryPoint-annotated function names to function-table indices.

BUNDLE_METADATA (format v2+)

bundleIdIdx:u16
composableCount:u16
[functionIdx:i32 + nameIdx:u16 + paramCount:u16 + paramIdx:u16* +
 hasDefaults:u8 + isNativeCapability:u8 + capabilityId:u16]*
viewModelCount:u16
[nameIdx:u16 + initFnIdx:i32 + eventCount:u16 + [keyIdx:u16 + fnIdx:i32]* +
 stateCount:u16 + stateIdx:u16*]*
minAppVersion:i32                ← additive trailing field

The trailing minAppVersion is read with an available() >= 4 guard. Pre-2.1 bundles without it default to 0 (universally compatible).


Signature

The last 64 bytes are the Ed25519 signature of all preceding bytes (header + sections). When UNSIGNED flag is set, the trailer is 64 zero bytes; otherwise it's a real signature computed by Ed25519Signer.sign(payload) with the build-time private key.

Verification is done by Ed25519Verifier.verify(bundleBytes, publicKey) before any section parsing. A failed verification throws KetoyBundleSignatureException and the reader doesn't touch the body.


Wire-format invariants

These are guaranteed across all 0.3.x releases:

  • Magic bytes never change.
  • Format version is forward-compatible: minor revisions add fields at end-of-section (with available() guards on read) or new section types (added to the strict ordering table).
  • Bundle IDs and entry-point names are pool-indexed UTF-8 strings.
  • Section envelopes always include uncompressedSize and onDiskSize so readers can skip unknown sections without parsing.

Reading a bundle programmatically

kotlin
import dev.ketoy.bundle.KtxReader

val bytes: ByteArray = File("main.ktx").readBytes()
val reader = KtxReader(
    publicKey = myPublicKey32Bytes,
    verifySignature = true,
)
val bundle = reader.read(bytes)

println("Bundle: ${bundle.id}")
println("Format version: ${bundle.formatVersion}")
println("Functions: ${bundle.functions.size}")
println("Composables: ${bundle.composables.size}")
println("Entry points: ${bundle.entryPoints.keys}")
println("min app version: ${bundle.minAppVersion}")

For unsigned dev bundles, set verifySignature = false and skip publicKey.


Writing a bundle programmatically

kotlin
import dev.ketoy.bundle.KtxWriter
import dev.ketoy.bundle.Ed25519Signer

val signer = Ed25519Signer(privateKeySeed = my32Bytes)
val writer = KtxWriter(debugMode = false, compress = true)
val bytes: ByteArray = writer.write(bundle, signer = signer)
File("main.ktx").writeBytes(bytes)

Pass signer = null to produce an unsigned bundle (sets the UNSIGNED flag).


File-size expectations

Bundle complexityTypical size
Hello-world screen (1 entry point, ~7 functions, ~5 composable calls)1.5 KB signed
Mid-size todo list (~22 functions, ~30 composable calls, captures)3.5 KB signed
Multi-screen app surface with ViewModels8–20 KB signed

Brotli compression on CODE typically gets 2–3× over raw bytecode. Everything else stays raw because the sections are small.

Next: Capability Registry →