Last active
March 21, 2021 17:38
-
-
Save jaames/69af90df7a51b56ba04826bd7660420d to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?php | |
class KwzParser { | |
protected $data = null; | |
protected $offset = 0; | |
protected $size = 0; | |
public $sections = []; | |
public $meta = null; | |
public $frameMeta = null; | |
public $soundMeta = null; | |
private $pubkey = <<<EOT | |
-----BEGIN PUBLIC KEY----- | |
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuv+zHAXXvbbtRqxADDeJ | |
ArX2b9RMxj3T+qpRg3FnIE/jeU3tj7eoDzsMduY+D/UT9CSnP+QHYY/vf0n5lqX9 | |
s6ljoZAmyUuruyj1e5Bg+fkDEu/yPEPQjqhbyywCyYL4TEAOJveopUBx9fdQxUJ6 | |
J4J5oCE/Im1kFrlGW+puARiHmt3mmUyNzO8bI/Jx3cGSfoOHJG1foEaQsI5aaKqA | |
pBqxtzvwqMhudcZtAWSyRMBMlndvkRnVTDNTfTXLOYdHShCIgnKULCTH87uLBIP/ | |
nsmr4/bnQz8q2rp/HyVO+0yjR6mVr0NX5APJQ+6riJmGg3t3VOldhKP7aTHDUW+h | |
kQIDAQAB | |
-----END PUBLIC KEY----- | |
EOT; | |
// $data should be KWZ data formatted as a string (such as from file_get_contents()) | |
public function __construct(string $data) | |
{ | |
if ($data) $this->load($data); | |
} | |
public function load(string $data) | |
{ | |
$this->offset = 0; | |
$this->data = $data; | |
$this->size = strlen($data); | |
$this->sections = []; | |
$this->meta = null; | |
$this->frameMeta = null; | |
$this->soundMeta = null; | |
// Build section table | |
$fileSize = $this->size - 256; | |
$numSections = 0; | |
while (($this->offset < $fileSize) && ($numSections < 6)) | |
{ | |
$sectionStart = $this->offset; | |
$sectionHeader = $this->unpack([ | |
'type' => 'a3', | |
'flags' => 'C', | |
'length' => 'V', | |
], 8); | |
$this->sections[$sectionHeader['type']] = [ | |
'flags' => $sectionHeader['flags'], | |
'length' => $sectionHeader['length'], | |
'offset' => $sectionStart | |
]; | |
// Seek to the start of the next section | |
$this->seek($this->offset + $sectionHeader['length']); | |
$numSections += 1; | |
} | |
} | |
public function close() { | |
$this->offset = 0; | |
$this->data = null; | |
$this->size = 0; | |
$this->sections = []; | |
$this->meta = null; | |
$this->frameMeta = null; | |
$this->soundMeta = null; | |
} | |
public function getSectionData(string $type) | |
{ | |
$sectionStart = 0; | |
$sectionSize = 0; | |
if ($type === 'KMI') | |
{ | |
$sectionStart = 8; | |
$sectionSize = $this->sections[$type]['length']; | |
} | |
elseif ($type === 'KSN') | |
{ | |
$sectionStart = 36; | |
$sectionSize = $this->sections[$type]['length'] - 28; | |
} | |
else | |
{ | |
$sectionStart = 12; | |
$sectionSize = $this->sections[$type]['length'] - 4; | |
} | |
$this->seekToSection($type); | |
$this->seek($sectionStart, 1); | |
return $this->read($sectionSize); | |
} | |
public function getSectionHash(string $type) { | |
return md5($this->getSectionData($type)); | |
} | |
public function getMeta() | |
{ | |
if (isset($this->meta)) return $this->meta; | |
$this->seekToSection('KFH'); | |
// Skip past section header and checksum | |
$this->seek(12, 1); | |
$meta = $this->unpack([ | |
'creationTimestamp' => 'V', | |
'modifiedTimestamp' => 'V', | |
'appVersion' => 'V', | |
'rootAuthorId' => 'H20', | |
'parentAuthorId' => 'H20', | |
'currentAuthorId' => 'H20', | |
'rootAuthorName' => 'a22', | |
'parentAuthorName' => 'a22', | |
'currentAuthorName' => 'a22', | |
'rootFilename' => 'a28', | |
'parentFilename' => 'a28', | |
'currentFilename' => 'a28', | |
'frameCount' => 'v', | |
'thumbIndex' => 'v', | |
'flags' => 'v', | |
'frameSpeed' => 'C', | |
'layerFlags' => 'C', | |
], 204); | |
// Get frame author IDs | |
$frameAuthorIds = array_map(function ($frame) | |
{ | |
return $frame['fsid']; | |
}, $this->getFrameMeta()); | |
// Get sound meta | |
$soundMeta = $this->getSoundMeta(); | |
// Format metadata | |
$this->meta = [ | |
'lock' => ($meta['flags'] & 0x1) === 1, | |
'loop' => (($meta['flags'] >> 1) & 0x01) === 1, | |
'flags' => $meta['flags'], | |
'layer_flags' => $meta['layerFlags'], | |
'app_version' => $meta['appVersion'], | |
'frame_count' => $meta['frameCount'], | |
'frame_speed' => $meta['frameSpeed'], | |
'thumb_index' => $meta['thumbIndex'], | |
'modified' => $meta['modifiedTimestamp'] + 946684800, | |
'created' => $meta['creationTimestamp'] + 946684800, | |
'frame_fsids' => array_unique($frameAuthorIds), | |
'root' => [ | |
'username' => $this->formatUsername($meta['rootAuthorName']), | |
'fsid' => $this->formatAuthorId($meta['rootAuthorId']), | |
'filename' => $this->formatFilename($meta['rootFilename']), | |
], | |
'parent' => [ | |
'username' => $this->formatUsername($meta['parentAuthorName']), | |
'fsid' => $this->formatAuthorId($meta['parentAuthorId']), | |
'filename' => $this->formatFilename($meta['parentFilename']), | |
], | |
'current' => [ | |
'username' => $this->formatUsername($meta['currentAuthorName']), | |
'fsid' => $this->formatAuthorId($meta['currentAuthorId']), | |
'filename' => $this->formatFilename($meta['currentFilename']), | |
], | |
'track_frame_speed' => $soundMeta['track_frame_speed'], | |
'track_usage' => [ | |
'bgm' => $soundMeta['bgm_size'] > 0, | |
'se1' => $soundMeta['se1_size'] > 0, | |
'se2' => $soundMeta['se2_size'] > 0, | |
'se3' => $soundMeta['se3_size'] > 0, | |
'se4' => $soundMeta['se4_size'] > 0, | |
], | |
'bgm_digest' => md5($this->getSoundtrackData('bgm')) | |
]; | |
return $this->meta; | |
} | |
public function getFrameMeta() | |
{ | |
if (isset($this->frameMeta)) return $this->frameMeta; | |
$this->seekToSection('KMI'); | |
// Skip past section header | |
$this->seek(8, 1); | |
$offset = 0; | |
$size = $this->sections['KMI']['length']; | |
$this->frameMeta = []; | |
do | |
{ | |
$frame = $this->unpack([ | |
'colors' => 'V', | |
'layer_size' => 'v3', | |
'fsid' => 'H20', | |
'depth' => 'C3', | |
'sound' => 'C', | |
'flags' => 'V' | |
], 28); | |
$frame['fsid'] = $this->formatAuthorId($frame['fsid']); | |
array_push($this->frameMeta, $frame); | |
$offset += 28; | |
} | |
while ($offset <= $size - 28); | |
return $this->frameMeta; | |
} | |
public function getFrameData(int $frameIndex) | |
{ | |
$frameMetaTable = $this->getFrameMeta(); | |
// Calculate frame offset | |
$frameOffset = 0; | |
for ($i = 0; $i < $frameIndex; $i++) | |
{ | |
$frameMeta = $frameMetaTable[$i]; | |
$frameOffset += $frameMeta['layer_size1'] + $frameMeta['layer_size2'] + $frameMeta['layer_size3']; | |
} | |
$this->seekToSection('KMC'); | |
// Skip to the start of the frame data | |
$this->seek(12 + $frameOffset, 1); | |
// Get size of current frame | |
$frameMeta = $frameMetaTable[$frameIndex]; | |
$frameSize = $frameMeta['layer_size1'] + $frameMeta['layer_size2'] + $frameMeta['layer_size3']; | |
return $this->read($frameSize); | |
} | |
public function getFrameHash(int $frameIndex) | |
{ | |
return md5($this->getFrameData($frameIndex)); | |
} | |
public function getFrameHashes() | |
{ | |
$meta = $this->getMeta(); | |
$hashes = []; | |
for ($frameIndex = 0; $frameIndex < $meta['frame_count']; $frameIndex++) | |
{ | |
array_push($hashes, $this->getFrameHash($frameIndex)); | |
} | |
return $hashes; | |
} | |
public function getSoundMeta() | |
{ | |
if (isset($this->soundMeta)) return $this->soundMeta; | |
$this->seekToSection('KSN'); | |
// Skip past section header | |
$this->seek(8, 1); | |
$this->soundMeta = $this->unpack([ | |
'track_frame_speed' => 'V', | |
'bgm_size' => 'V', | |
'se1_size' => 'V', | |
'se2_size' => 'V', | |
'se3_size' => 'V', | |
'se4_size' => 'V', | |
], 48); | |
return $this->soundMeta; | |
} | |
public function getSoundtrackData(string $id) | |
{ | |
$soundMeta = $this->getSoundMeta(); | |
$offset = 0; | |
foreach (['bgm', 'se1', 'se2', 'se3', 'se4'] as $key) | |
{ | |
if ($key === $id) break; | |
$offset += $soundMeta[$key . '_size']; | |
} | |
$this->seekToSection('KSN'); | |
// Skip to start of sound data | |
$this->seek($offset + 28, 1); | |
return $this->read($soundMeta[$id . '_size']); | |
} | |
public function isSignatureValid() | |
{ | |
$this->seek(0); | |
$content = $this->read($this->size - 256); | |
$signature = $this->read(256); | |
return openssl_verify($content, $signature, $this->pubkey, 'sha256WithRSAEncryption') === 1; | |
} | |
public function areSectionsValid() | |
{ | |
$areValid = []; | |
foreach ($this->sections as $type => $section) | |
{ | |
if (in_array($type, ['KFH', 'KTN', 'KMC'])) | |
{ | |
$this->seekToSection($type); | |
$this->seek(8, 1); // skip section header | |
$sectionSize = $this->sections[$type]['length']; | |
$checksum = unpack('V', $this->read(4))[1]; // read checksum | |
$body = $this->getSectionData($type); | |
$areValid[$type] = $checksum === crc32($body); | |
} | |
} | |
// KMI section doesn't have a checksum | |
// KSN checksum is in a different place, not rly sure why but ok nintendo | |
if (array_key_exists('KSN', $this->sections)) | |
{ | |
$this->seekToSection('KSN'); | |
$this->seek(8 + 24, 1); // skip section header + sound meta | |
$sectionSize = $this->sections['KSN']['length']; | |
$checksum = unpack('V', $this->read(4))[1]; | |
$body = $this->getSectionData($type); | |
$areValid[$type] = $checksum === crc32($body); | |
} | |
return $areValid; | |
} | |
public function validate() { | |
$meta = $this->getMeta(); | |
$frameMeta = $this->getFrameMeta(); | |
$soundMeta = $this->getSoundMeta(); | |
$err = []; | |
// Check signature validity | |
if (!$this->isSignatureValid()) | |
{ | |
array_push($err, 'SIGNATURE_INVALID'); | |
} | |
// Check that the sum of the section + checksum lengths match the data length | |
$calcSize = 0; | |
foreach ($this->sections as $type => $section) | |
{ | |
$calcSize += $section['length'] + 8; | |
} | |
if ($calcSize + 256 !== $this->size) | |
{ | |
array_push($err, 'FILE_SIZE_INVALID'); | |
} | |
// Check sections are present and are the correct length | |
foreach (['KFH', 'KTN', 'KMC', 'KMI', 'KSN'] as $type) | |
{ | |
if (!array_key_exists($type, $this->sections)) | |
{ | |
array_push($err, $type . '_SECTION_MISSING'); | |
} | |
if (($type === 'KFH') && ($this->sections['KFH']['length'] !== 204)) | |
{ | |
array_push($err, 'KFH_SIZE_INVALID'); | |
} | |
if (($type === 'KMI') && ($meta['frame_count'] !== $this->sections['KMI']['length'] / 28)) | |
{ | |
array_push($err, 'KMI_SIZE_INVALID'); | |
} | |
if ($type === 'KMC') | |
{ | |
$calcSize = 0; | |
foreach ($frameMeta as &$frame) | |
{ | |
$calcSize += $frame['layer_size1'] + $frame['layer_size2'] + $frame['layer_size3']; | |
} | |
if ($calcSize > $this->sections['KMC']['length']) array_push($err, 'KMC_SIZE_INVALID'); // (allowing for padding) | |
} | |
if ($type === 'KSN') | |
{ | |
$calcSize = 0; | |
foreach (['bgm', 'se1', 'se2', 'se3', 'se4'] as $key) | |
{ | |
$calcSize += $soundMeta[$key . '_size']; | |
} | |
if ($calcSize + 28 > $this->sections['KSN']['length']) array_push($err, 'KSN_SIZE_INVALID'); // (allowing for padding) | |
} | |
} | |
// Check section crc32 hashes | |
foreach ($this->areSectionsValid() as $type => $isValid) | |
{ | |
if (!$isValid) array_push($err, $type . '_CRC32_INVALID'); | |
} | |
// Validate metadata | |
// ==== | |
// Check frame count matches number of frames in | |
if ($meta['frame_count'] !== $this->sections['KMI']['length'] / 28) { | |
array_push($err, 'FRAME_COUNT_INVALID'); | |
} | |
// Check frame speed is in valid range | |
if ($meta['frame_speed'] > 10) | |
{ | |
array_push($err, 'FRAME_SPEED_INVALID'); | |
} | |
// Check recording frame speed is in valid range | |
if ($meta['track_frame_speed'] > 10) | |
{ | |
array_push($err, 'TRACK_FRAME_SPEED_INVALID'); | |
} | |
// Check thumb index is in valid range | |
if ($meta['thumb_index'] > $meta['frame_count'] - 1) | |
{ | |
array_push($err, 'THUMB_INDEX_INVALID'); | |
} | |
// Check current user is in frame authors | |
if (!in_array($meta['current']['fsid'], $meta['frame_fsids'])) | |
{ | |
array_push($err, 'FRAME_AUTHOR_INVALID'); | |
} | |
return $err; | |
} | |
private function formatUsername(string $username) | |
{ | |
return trim(mb_convert_encoding($username, 'UTF-8', 'UTF-16LE')); | |
} | |
private function formatAuthorId(string $id) | |
{ | |
// Could trim the trailing null byte here, I don't think it's used, but I'm gonna leave it and see if Nintendo surprises us | |
return strtoupper($id); | |
} | |
private function formatFilename(string $filename) | |
{ | |
// If filename matches Flipnote Studio's PPM format, unpack it | |
if (preg_match('/^[\x{00}-\x{FF}]{3}[A-Fa-f0-9]{13}[\x{00}-\x{FF}]{3}/', $filename)) | |
{ | |
$f = unpack('H6mac/a13random/vedits', $filename); | |
return strtoupper(sprintf('%6s_%13s_%03d', $f['mac'], $f['random'], $f['edits'])); | |
} | |
return $filename; | |
} | |
private function read(int $nbytes = 1) | |
{ | |
$ret = substr($this->data, $this->offset, $nbytes); | |
$this->offset += $nbytes; | |
return $ret; | |
} | |
private function seek(int $offset, int $whence = 0) | |
{ | |
switch ($whence) { | |
case 2: | |
$this->offset = $this->size + $offset; | |
break; | |
case 1: | |
$this->offset += $offset; | |
break; | |
case 0: | |
default: | |
$this->offset = $offset; | |
break; | |
} | |
} | |
private function seekToSection(string $type) | |
{ | |
$this->seek($this->sections[$type]['offset']); | |
} | |
private function unpack(array $struct, int $nbytes = 0) | |
{ | |
$structArr = []; | |
foreach ($struct as $name => $type) | |
{ | |
array_push($structArr, $type . $name); | |
} | |
return unpack(join('/', $structArr), $this->read($nbytes)); | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment