Last active
July 20, 2019 13:14
-
-
Save MikuAuahDark/44c6397752fd9a92fb1c to your computer and use it in GitHub Desktop.
SIF Decryption (prototype). The fully working project is in https://github.com/MikuAuahDark/HonokaMiku
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
--[[ | |
Love Live! School Idol Festival game files decoder/decrypter. Part of Project HonokaMiku | |
For SIF JP version. | |
LL!SIF JP file decryption routines written in pure Lua & tested in Lua 5.1.4 | |
Requires MD5 implementation in lua. https://github.com/kikito/md5.lua | |
It may not use same license below, open link above for more information. | |
Copyright © 2036 Dark Energy Processor Corporation | |
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and | |
associated documentation files (the "Software"), to deal in the Software without restriction, | |
including without limitation the rights to use, copy, modify, merge, publish, distribute, | |
sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is | |
furnished to do so, subject to the following conditions: | |
The above copyright notice and this permission notice shall be included in all copies or substantial | |
portions of the Software. | |
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT | |
NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND | |
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, | |
DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | |
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. | |
]] | |
local keyTables={ | |
1210253353 ,1736710334 ,1030507233 ,1924017366 ,1603299666 ,1844516425 ,1102797553 ,32188137, | |
782633907 ,356258523 ,957120135 ,10030910 ,811467044 ,1226589197 ,1303858438 ,1423840583, | |
756169139 ,1304954701 ,1723556931 ,648430219 ,1560506399 ,1987934810 ,305677577 ,505363237, | |
450129501 ,1811702731 ,2146795414 ,842747461 ,638394899 ,51014537 ,198914076 ,120739502, | |
1973027104 ,586031952 ,1484278592 ,1560111926 ,441007634 ,1006001970 ,2038250142 ,232546121, | |
827280557 ,1307729428 ,775964996 ,483398502 ,1724135019 ,2125939248 ,742088754 ,1411519905, | |
136462070 ,1084053905 ,2039157473 ,1943671327 ,650795184 ,151139993 ,1467120569 ,1883837341, | |
1249929516 ,382015614 ,1020618905 ,1082135529 ,870997426 ,1221338057 ,1623152467 ,1020681319 | |
} | |
local keyMultipler=214013 | |
local keyAdd=2531011 | |
md5=dofile("md5.lua") -- source: https://github.com/kikito/md5.lua | |
local bxor | |
local bnot | |
do | |
local _,bit=pcall(require,"bit") | |
if _ then | |
bxor=bit.bxor | |
bnot=bit.bnot | |
else | |
_,bit=pcall(require,"bit32") | |
if _ then | |
bxor=function(a,b) return bit.bxor(a>2147483647 and a-4294967296 or a,b>2147483647 and b-4294967296 or b) end | |
bnot=bit.bnot | |
else | |
bxor=function(a,b) -- source: http://stackoverflow.com/a/25594410 | |
local p,c=1,0 | |
while a>0 and b>0 do | |
local ra,rb=a%2,b%2 | |
if ra~=rb then c=c+p end | |
a,b,p=(a-ra)/2,(b-rb)/2,p*2 | |
end | |
if a<b then a=b end | |
while a>0 do | |
local ra=a%2 | |
if ra>0 then c=c+p end | |
a,p=(a-ra)/2,p*2 | |
end | |
return c | |
end | |
bnot=function(n) | |
local p,c=1,0 | |
while n>0 do | |
local r=n%2 | |
if r<1 then c=c+p end | |
n,p=(n-r)/2,p*2 | |
end | |
return c | |
end | |
end | |
end | |
end | |
-- Returns the XOR key and the new multipler key. | |
-- Unlike SIF EN decrypter, both values returned as numbers instead. | |
local function updateKey(mkey) | |
local a=(mkey*keyMultipler+keyAdd)%4294967296 | |
return math.floor(a/16777216),a | |
end | |
-- Decrypter setup. | |
-- Header is the first 16-bytes file contents | |
-- File is the file path in string. Actually the function just needs the basename | |
-- Returns the decrypter context/structure | |
function decryptSetup(header,file) | |
local hash=md5.sum("Hello"..file:sub(-(file:reverse():find("/") or file:reverse():find("\\") or 0)+1)) | |
local w=hash:gmatch("....") | |
local t={} | |
w() -- discard | |
local hchk=w() | |
hchk=bnot(hchk:sub(1,1):byte()+hchk:sub(2,2):byte()*256+hchk:sub(3,3):byte()*65536+hchk:sub(4,4):byte()*16777216) | |
hchk=string.char(hchk%256)..string.char(math.floor(hchk/256)%256)..string.char(math.floor(hchk/65536)%256) | |
assert(hchk==header:sub(1,3),"Header file doesn't match!") -- Only decrypt mode 3 is supported. | |
local idx=header:sub(12,12):byte()%64+1 -- +1 because Lua is 1-based indexing | |
t.init_key=keyTables[idx] | |
t.update_key=t.init_key | |
t.xor_key=math.floor(t.init_key/16777216) | |
t.pos=0 | |
return t | |
end | |
-- Used internally. Updates the key | |
local function updateKeyStruct(dctx) | |
dctx.xor_key,dctx.update_key=updateKey(dctx.update_key) | |
end | |
-- Decrypt block of string(bytes). | |
-- dctx is decrypter context/struct | |
-- b is bytes that want to decrypted | |
-- Returns decrypted bytes | |
function decryptBlock(dctx,b) | |
if #b==0 then return end | |
local t={} | |
local char=string.char | |
local inst=table.insert | |
for i=1,#b do | |
table.insert(t,char(bxor(dctx.xor_key,b:sub(i,i):byte()))) | |
updateKeyStruct(dctx) | |
dctx.pos=dctx.pos+1 | |
end | |
return table.concat(t) | |
end | |
-- Sets decrypter key position to decrypt at <pos+offset> later. | |
-- dctx is decrypter context | |
-- offset is relative to current position | |
-- UNTESTED! | |
function gotoOffset(dctx,offset) | |
if offset==0 then return end | |
local x=dctx.pos+offset | |
assert(x>=0,"Negative position") | |
dctx.pos=x | |
local floor=math.floor | |
dctx.update_key=dctx.init_key | |
dctx.xor_key=floor(key/16777216) | |
if x>0 then | |
for i=1,x do | |
updateKeyStruct(dctx) | |
end | |
end | |
end | |
--[[ | |
So, example decryption flow: | |
local path="file/to/tx_u_41001001_rankup_navi.texb" | |
local f=io.open(path,"rb") | |
local dctx=decryptSetup(f:read(16),path) | |
local f2=io.open("Honoka card #79.texb","wb") | |
f2:write(decryptBlock(dctx,f:read("*a"))) | |
f2:close() | |
f:close() | |
Decrypted file is stored as "Honoka card #79.texb" | |
]] |
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
--[[ | |
Love Live! School Idol Festival game files decoder/decrypter. Part of Project HonokaMiku | |
For SIF EN version. | |
LL!SIF EN file decryption routines written in pure Lua & tested in Lua 5.1.4 | |
Requires MD5 implementation in lua. https://github.com/kikito/md5.lua | |
It may not use same license below, open link above for more information. | |
Copyright © 2036 Dark Energy Processor Corporation | |
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and | |
associated documentation files (the "Software"), to deal in the Software without restriction, | |
including without limitation the rights to use, copy, modify, merge, publish, distribute, | |
sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is | |
furnished to do so, subject to the following conditions: | |
The above copyright notice and this permission notice shall be included in all copies or substantial | |
portions of the Software. | |
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT | |
NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND | |
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, | |
DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | |
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. | |
]] | |
md5=dofile("md5.lua") -- source: https://github.com/kikito/md5.lua | |
local bxor | |
do | |
local _,bit=pcall(require,"bit") | |
if _ then bxor=bit.bxor | |
else | |
_,bit=pcall(require,"bit32") | |
if _ then bxor=function(a,b) return bit.bxor(a>2147483647 and a-4294967296 or a,b>2147483647 and b-4294967296 or b) end | |
else | |
bxor=function(a,b) -- source: http://stackoverflow.com/a/25594410 | |
local p,c=1,0 | |
while a>0 and b>0 do | |
local ra,rb=a%2,b%2 | |
if ra~=rb then c=c+p end | |
a,b,p=(a-ra)/2,(b-rb)/2,p*2 | |
end | |
if a<b then a=b end | |
while a>0 do | |
local ra=a%2 | |
if ra>0 then c=c+p end | |
a,p=(a-ra)/2,p*2 | |
end | |
return c | |
end | |
end | |
end | |
end | |
-- Returns the XOR key(2 bytes) and the new multipler key. In new tables | |
-- We use decimals instead of hexadecimals. I think it looks cool | |
local function updateKey(mkey) | |
local floor=math.floor | |
local _={unpack(mkey)} | |
local a=mkey[1]+mkey[2]*256+mkey[3]*65536+mkey[4]*16777216 | |
local b=floor(a/65536)%65536 | |
local c=(b*1101463552)%2147483648 | |
local d=c+(a%65536)*16807 | |
local e=floor(b*16807/32768) | |
local f=e+d-2147483647 | |
if d%4294967296>2147483646 then d=f | |
else d=d+e end | |
return { | |
floor(d/8388608)%256, | |
floor(d/32768)%256 | |
}, | |
{ | |
d%256, | |
floor(d/256)%256, | |
floor(d/65536)%256, | |
floor(d/16777216) | |
} | |
end | |
-- Decrypter setup. | |
-- Header is the first 4-bytes file contents | |
-- File is the file path in string. Actually the function just needs the basename | |
-- Returns the decrypter context/structure | |
function decryptSetup(header,file) | |
local hash=md5.sum("BFd3EnkcKa"..file:sub(-(file:reverse():find("/") or file:reverse():find("\\") or 0)+1)) | |
local t={} | |
local w=hash:sub(1,8):gmatch("....") | |
local key=w() | |
assert(header==w(),"Header file doesn't match!") -- Currently only one decryption method is supported | |
local uk={ | |
key:sub(4,4):byte(), | |
key:sub(3,3):byte(), | |
key:sub(2,2):byte(), | |
key:sub(1,1):byte()%128 | |
} | |
key=uk[1]+uk[2]*256+uk[3]*65536+uk[4]*16777216 | |
t.xor_key={math.floor(key/8388608)%256,math.floor(key/32768)%256} | |
t.pos=0 | |
t.update_key=uk | |
t.init_key={unpack(uk)} | |
return t | |
end | |
-- Used internally. Updates the key | |
local function updateKeyStruct(dctx) | |
local a,b=updateKey(dctx.update_key) | |
dctx.update_key=b | |
dctx.xor_key=a | |
end | |
-- Decrypt block of string(bytes). | |
-- dctx is decrypter context/struct | |
-- b is bytes that want to decrypted | |
-- Returns decrypted bytes | |
function decryptBlock(dctx,b) | |
if #b==0 then return end | |
local char=string.char | |
local inst=table.insert | |
local t={} | |
if dctx.pos%2==1 then | |
inst(t,char(bxor(b:sub(1,1):byte(),dctx.xor_key[2]))) | |
updateKeyStruct(dctx) | |
dctx.pos=dctx.pos+1 | |
bytes=bytes:sub(2) | |
end | |
for i=1,#b do | |
if i%2==1 then | |
inst(t,char(bxor(b:sub(i,i):byte(),dctx.xor_key[1]))) | |
else | |
inst(t,char(bxor(b:sub(i,i):byte(),dctx.xor_key[2]))) | |
updateKeyStruct(dctx) | |
end | |
dctx.pos=dctx.pos+1 | |
end | |
return table.concat(t) | |
end | |
-- Sets decrypter key position to decrypt at <pos+offset> later. | |
-- dctx is decrypter context | |
-- offset is relative to current position | |
-- Currently slow and experimental | |
function gotoOffset(dctx,offset) | |
if offset==0 then return end | |
local x=dctx.pos+offset | |
assert(x>=0,"Negative position") | |
dctx.pos=x | |
local floor=math.floor | |
dctx.update_key={unpack(dctx.init_key)} | |
local key=dctx.update_key[1]+dctx.update_key[2]*256+dctx.update_key[3]*65536+dctx.update_key[4]*16777216 | |
dctx.xor_key={floor(key/8388608)%256,floor(key/32768)%256} | |
if x>1 then | |
for i=1,floor(x/2) do | |
updateKeyStruct(dctx) | |
end | |
end | |
end | |
--[[ | |
So, example decryption flow: | |
local path="file/to/tx_u_41001001_rankup_navi.texb" | |
local f=io.open(path,"rb") | |
local dctx=decryptSetup(f:read(4),path) | |
local f2=io.open("Honoka card #79.texb","wb") | |
f2:write(decryptBlock(dctx,f:read("*a"))) | |
f2:close() | |
f:close() | |
Decrypted file is stored as "Honoka card #79.texb" | |
]] |
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
// HonokaMiku.cpp | |
// Loads SIF libGame.so and execute it's decrypt function | |
// 10/15/2015: JP is now supported. Requires SIF JP v2.0.5 x86 libGame.so | |
#if !defined(_M_IX86) && !defined(__i386__) | |
#error "Only x86 targets are supported!" | |
#endif | |
#include <cstdlib> | |
#include <cstdio> | |
#include <iostream> | |
#include <Windows.h> | |
#include <direct.h> // change this to unistd.h if you want to build in Linux | |
#ifndef _MSC_VER | |
#include <libgen.h> | |
#endif | |
#ifndef SIF_JP | |
// So it's easy to change it if we want to use JP libGame.so for example. | |
// Relative to SIF EN v2.0.5 x86 libGame.so | |
#define LIBGAME_STRLEN_PTR 0x73170 | |
#define LIBGAME_MEMCPY_PTR 0x73160 | |
#define LIBGAME_MEMSET_PTR 0x73180 | |
#define LIBGAME_MALLOC_PTR 0x73130 | |
#define LIBGAME_FREE_PTR 0x73140 | |
#define LIBGAME_ASRTLOG_PTR 0x210280 | |
#define DECRYPTER_STRUCTSIZE 0x40 | |
#define DECRYPTER_SETUPFUNC 0x189860 | |
#define DECRYPTER_DECRYPT 0x189480 | |
// Some patched code address. Without patching it, we will very likely to get Access Violation | |
#define DECRYPTSETUP_NOP1 0x189052 | |
#define DECRYPTSETUP_NOP1_SIZE 15 | |
#define DECRYPTSETUP_NOP2 0x1893ac | |
#define DECRYPTSETUP_NOP3 0x1890ab | |
#define DECRYPTSETUP_NOP3_SIZE 14 | |
#else | |
// Relative to SIF JP v2.0.5 x86 libGame.so | |
#define LIBGAME_STRLEN_PTR 0x72fb0 | |
#define LIBGAME_MEMCPY_PTR 0x72fa0 | |
#define LIBGAME_MEMSET_PTR 0x72fc0 | |
#define LIBGAME_MALLOC_PTR 0x72f70 | |
#define LIBGAME_FREE_PTR 0x72f80 | |
#define LIBGAME_ASRTLOG_PTR 0x2245a0 | |
#define DECRYPTER_STRUCTSIZE 0x4c | |
#define DECRYPTER_SETUPFUNC 0x19b6c0 | |
#define DECRYPTER_SETUPFUNC2 0x19b7e0 | |
#define DECRYPTER_DECRYPT 0x19b970 | |
#define DECRYPTSETUP_NOP1 0x19ad92 | |
#define DECRYPTSETUP_NOP1_SIZE 19 | |
#define DECRYPTSETUP_NOP3 0x19b0d5 | |
#define DECRYPTSETUP_NOP3_SIZE 15 | |
#endif | |
static char libGame[10485760]; // 10MB of char array | |
typedef int (*decryptSetupExPre)(void* ,const char* ,const char * ,unsigned int& ); | |
typedef void (*decrypt)(void* ,void* ,size_t); | |
#ifdef SIF_JP | |
typedef void (*decryptSetupExPost)(void* ,const char* ); | |
#endif | |
void setFuncPtr(void* lib_address,unsigned int func_address_in_file,void* func_ptr) { | |
char* lib_addr_as_char=(char*)lib_address; | |
memset((void*)((unsigned int)lib_address+func_address_in_file),0x90,16); // 0x90 = xchg eax,eax a.k.a nop | |
lib_addr_as_char[func_address_in_file]=0xe9; // 0xe9 = jmp relative | |
*(unsigned int*)((unsigned int)lib_address+func_address_in_file+1)=(unsigned int)func_ptr-((unsigned int)lib_address+func_address_in_file)-5; | |
} | |
void assertLogFunction(int code,char* file,char* message) { | |
std::cerr << "Error " << code << "." << std::endl << file << ": " << message << std::endl; | |
exit(-1); | |
} | |
void initLibGame() { | |
char curdir[260]; | |
char formatted_dir[260]; | |
char header[4]; | |
GetModuleFileNameA(nullptr,curdir,260); // change this if you want to build in Linux | |
{ | |
char* temp=strrchr(curdir,'\\'); // change this if you want to build in Linux | |
temp[1]=0; | |
} | |
sprintf(formatted_dir,"%s%s",curdir,"libGame.so"); | |
FILE* f=fopen(formatted_dir,"rb"); | |
if(f==nullptr) { | |
std::cerr << "Cannot load libGame.so: " << strerror(errno) << std::endl; | |
exit(-1); | |
} | |
fread(header,1,4,f); | |
if(memcmp(header,"\177ELF",4)) { | |
std::cerr << "Not a valid .so file" << std::endl; | |
exit(-1); | |
} | |
fseek(f,0,SEEK_END); | |
size_t file_size=ftell(f); | |
fseek(f,0,SEEK_SET); | |
//libGame=new char[file_size]; | |
fread(libGame,1,10485760,f); | |
fclose(f); | |
// Patch function in .so file | |
setFuncPtr(libGame,LIBGAME_STRLEN_PTR,(void*)&strlen); | |
setFuncPtr(libGame,LIBGAME_MEMCPY_PTR,(void*)&memcpy); | |
setFuncPtr(libGame,LIBGAME_MEMSET_PTR,(void*)&memset); | |
setFuncPtr(libGame,LIBGAME_MALLOC_PTR,(void*)&malloc); | |
setFuncPtr(libGame,LIBGAME_FREE_PTR,(void*)&free); | |
setFuncPtr(libGame,LIBGAME_ASRTLOG_PTR,(void*)&assertLogFunction); | |
// NOP some opcodes | |
memset(libGame+DECRYPTSETUP_NOP1,0x90,DECRYPTSETUP_NOP1_SIZE); | |
#ifndef SIF_JP | |
memset(libGame+DECRYPTSETUP_NOP2,0x90,17); | |
#endif | |
memset(libGame+DECRYPTSETUP_NOP3,0x90,DECRYPTSETUP_NOP3_SIZE); | |
// Allow execution in our allocated memory | |
DWORD old_protect; | |
VirtualProtect(libGame,10485760,PAGE_EXECUTE_READWRITE,&old_protect); // change this if you want to build in Linux | |
// Setup VTables. JP Ver only | |
#ifdef SIF_JP | |
*(unsigned int*)(libGame+0x3a1ff0)=(unsigned int)(libGame+0x39c1f8); | |
for(int i=0;i<4;i++) { | |
*(unsigned int*)(libGame+0x39c1f8+i*8)+=(unsigned int)libGame; | |
} | |
#endif | |
} | |
char* get_basename(char* path) { | |
char* temp=strrchr(path,'/'); | |
if(temp==nullptr) { | |
temp=strrchr(path,'\\'); | |
if(temp==nullptr) return path; | |
} | |
return temp+1; | |
} | |
int main(int argc,char** argv) { | |
char* decrypt_struct; | |
char* buffer; | |
FILE* f; | |
unsigned int uiv=0; | |
size_t file_size; | |
char* file_path; | |
char* file_out=nullptr; | |
if(argc<2) { | |
std::cout << "Usage: " << argv[0] << " <encrypted file> [decrypted file out]" << std::endl; | |
return -1; | |
} | |
if(argc>=3) | |
file_out=argv[2]; | |
f=fopen(argv[1],"rb"); | |
if(f==nullptr) { | |
std::cerr << "Cannot open " << argv[1] << ": " << strerror(errno) << std::endl; | |
return -1; | |
} | |
fseek(f,0,SEEK_END); | |
file_size=ftell(f); | |
fseek(f,0,SEEK_SET); | |
initLibGame(); | |
decryptSetupExPre decrypt_preparation=decryptSetupExPre((void*)(libGame+DECRYPTER_SETUPFUNC)); | |
#ifdef SIF_JP | |
decryptSetupExPost decrypt_finish_setup=decryptSetupExPost((void*)(libGame+DECRYPTER_SETUPFUNC2)); | |
#endif | |
decrypt decrypt_func=(decrypt)(void*)(libGame+DECRYPTER_DECRYPT); | |
decrypt_struct=new char[DECRYPTER_STRUCTSIZE]; | |
memset(decrypt_struct,0,0x40); | |
// decrypt_struct[0x22]=0xff; | |
// decrypt_struct[0x23]=0xff; | |
#ifdef SIF_JP | |
decrypt_struct[0x2d]=1; | |
#endif | |
buffer=new char[4]; | |
fread(buffer,1,4,f); | |
int val=decrypt_preparation(decrypt_struct,argv[1],buffer,uiv); // KLab decrypter actually can handle '\' and '/' | |
if(val) { | |
#ifdef SIF_JP | |
delete[] buffer; | |
buffer=new char[64]; | |
fread(buffer,1,uiv-4,f); | |
decrypt_finish_setup(decrypt_struct,buffer); | |
#endif | |
fseek(f,uiv,SEEK_SET); | |
delete[] buffer; | |
size_t bufsize=file_size-uiv; | |
buffer=new char[bufsize]; | |
fread(buffer,1,bufsize,f); | |
decrypt_func(decrypt_struct,buffer,bufsize); | |
fclose(f); | |
if(file_out==nullptr) { | |
file_out=new char[260]; | |
sprintf(file_out,"%s_",argv[1]); | |
} | |
f=fopen(file_out,"wb"); | |
if(f==nullptr) { | |
std::cerr << "Cannot open " << file_out << ": " << strerror(errno) << std::endl << "Writing to stdout instead!" << std::endl; | |
f=stdout; | |
} | |
fwrite(buffer,1,bufsize,f); | |
fclose(f); | |
delete[] buffer; | |
return 0; | |
} else { | |
std::cerr << "Something's wrong." << std::endl; | |
#ifdef _MSC_VER | |
_CrtDbgBreak(); | |
#else | |
return -1; | |
#endif | |
} | |
system("pause"); | |
return 0; | |
} | |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment