Unable to read files using sceIoRead (sync and async)

  • Thread starter Thread starter chisel
  • Start date Start date
  • Views Views 855
  • Replies Replies 7

chisel

New Member
Newbie
Joined
Jul 7, 2025
Messages
4
Reaction score
0
Trophies
0
XP
20
Country
Sweden
Hello

I have a PSP game that I want to apply a "patch" for, by reading a file I have inserted into its ISO. The file contains the script (dialogues and choices since it is a visual novel) of the game, decompressed and then edited. Which is supposed to be read and inserted into a specific memory location where the game's script resides in RAM (0x08DD9A80 ->). This has been confirmed to work by externally injecting bytes into that location, using a modifed version of PPSSPP (hardcoded function calls into it when a button combination is pressed, to insert all the bytes of the file into that specific memory location). (The game would not respond to cheat scripts, so went with a less common option for verifying that changing the script worked without issue).

The process has been this:
1. Identify where in the game I can insert my patch safely by checking instructions with PPSSPP, as well as Ghidra.
2. Successfully found a spot, and inserted my new function into it (by manually inserting it into the EBOOT using a hex editor after compiling the instructions, and hooking a function call early when the game starts up).
3. The function is executed and the game safely returns back to normal operation, no issue here.

Now then, the issue is that the file I am trying to read from the ISO simply cannot be "read". I am able to open it using sceIoOpen which provides me with a valid FD, I can then use sceIoLseek the file to get the file size (which works). But I am unable to read the file, all attempts lead to the error code 80020323, despite a valid FD (that was able to be used with seeking the file). PPSSPP does not log this error though (BADF log for example), but it does if I give it an invalid FD.

What I have tried:
1. Insert running my own thread. No change.
2. Use async reading instead, if anything may block the synchronous reading. No change (async read returns v0 = 0x0, a successful queue, but async wait also returns v0 = 0x0, thus no reading was performed).
3. Recreate the setup that the game originally uses for reading files (from what I could tell: Open the file, changeAsyncPriority(?), Lseek to the end then rewind, syncRead). This still did not work.
4. Began writing to uncached RAM instead of cached RAM (0x48DD9A80, which later on will be moved over to its cached counterpart.) I used to try and write to the cached RAM section, however since that failed I switched to uncached instead. No change was observed for reading though.

The game does read files right up to the point where I insert my function, well of course one would think that there is something blocking my read perhaps but not at the moment where my function is being executed. I hijacked the call to "install game on memory stick" to call my patched function instead. Doesn't matter if I place my function right at the start of the game either, as IoOpen & Lseek succeeds but any reading fails.

I will provide the function I currently use (it has a looping issue right now but that is not what I need to be fixed, that is something I can work on later. I need assistance with figuring out why reads fail). I apologize if the instructions are quite messy, I am not well knowledgable in writing 32 MIPS myself so had to consult chatGPT for most of the generating of instructions.


The registers contain the correct values all throughout the function, they do not get overwritten elsewhere during execution (for example FD is valid until I close it, it is not overwritten). Due to a whole lot of testing and messing around with the instructions, there may be other issues you can find but if someone could see where the reading issue lies that would be greatly appreciated.


Function:
.set noreorder
.text

# Trampolines
IO_CHG_PRIO = 0x0887ED9c # sceIoChangeAsyncPriority
IO_LSEEK = 0x0887EDb4 # sceIoLseek

IO_READ = 0x0887ED74 # sceIoRead
IO_OPEN = 0x0887EDAC
IO_READ_AS = 0x0887ED94
IO_WAIT_AS = 0x0887EDA4
IO_CLOSE = 0x0887ED8C
ERR_BUSY = 0x800201A7

.global _start
_start:
# FILE 1 : sn_decomp.bin
li $a0, 0x08890F34 # path_script, changed to hardcoded RAM offset where the string is inserted in RAM
li $a1, 0 # O_RDONLY
li $a2, 0x01FF # mode 0777
li $t9, IO_OPEN
jalr $t9
nop
move $s2, $v0 # keep fd in s2 (fd > 0)


# sceIoLseek(fd, 0, SEEK_END)
move $a0, $s2 # fd
li $a1, 0 # not used
li $a2, 0 # offset (low)
li $a3, 0 # offset (high)
li $t0, 2 # SEEK_END
li $t9, IO_LSEEK
jalr $t9
nop
move $s1, $v0 # bytes left

# second sceIoLseek(fd, 0, SEEK_SET)
move $a0, $s2 # fd
li $a1, 0 # not used
li $a2, 0 # offset (low)
li $a3, 0 # offset (high)
li $t0, 0 # SEEK_SET
li $t9, IO_LSEEK
jalr $t9
nop
jalr $t9
nop

li $t0, 0x48DD9A80 # uncached buffer


r1_loop:
beqz $t1, r1_done
nop
move $a0, $s2 # fd
move $a1, $t0 # buf
move $a2, $s1 # left
li $t9, IO_READ
jalr $t9
nop
blez $v0, r1_done
subu $t1, $t1, $v0
addu $t0, $t0, $v0
b r1_loop
nop

r1_done:
move $a0, $s2
li $t9, IO_CLOSE
jalr $t9
nop

# Return to original game flow
lui $ra, 0x0886
ori $ra, $ra, 0x4ADC # 0x08864ADC
jr $ra
nop


.section .rodata
path_script: .asciz "disc0:/PSP_GAME/USRDIR/patch/sn_decomp.bin"
.align 2



The reason why I do not simply hijack where the game originally reads the script file is due to the file being compressed with LZSS (LZ77 variant). The game then needs to decompress it, which means that I either need to fix a working compression script that managed to compress the decompressed file back into its original state (which never worked in previous attempts), or remove decompressing from the game. Removing the decompression could lead to issues further down the line if following behavior of the game depended on what it performed more than just the script itself. I chose to play it safe by letting the game start up normally and then just injecting my edited script directly into the "decompressed script" location in RAM. However, due to failing reads this seems to be an issue. I am also constrained by not being able to edit much in the EBOOT, except for free space, since if what I add is different in length then all offsets become misaligned (shorted edits can be padded without issue though).
 
Hello.

Is the error occurring in the first loop?
Do around codes of the safe location have processes of saving register?
The inserted function must respect the same calling conventions as the original.
But the t1 register is used to exit the loop without initialization, and that may be destroyed in the read function, also despite the s0-2 registers are used, those are not saved too.

First of all, you need to work it correctly either by deleting loop and reading once only or making properly, otherwise it can't be eliminated any possibilities.

Moreover, rather than achieving your goal in one go, I think you should insert at the same location simple test codes of trying to read several bytes from other files and trying to write that bytes or results to into MS for checking File I/O, and so on...

If it were me, I would choose the way to replace the LZSS compressed files with properly modified ones outside PSP.
 
Last edited by __YAS__,
I realized something else later.

I've forgotten because it's been a while since I did programming for PSP, is it correct that the a1 register is unused in the lseek call?
I can't imagine that the arguments would not be assigned to registers in order.
If the file isn't over 2GB(most likely), I think it's better to use the simpler lseek32.

And one more thing, JALR with NOP codes have duplicated in the second lseek call.
I don't see when the error occurs, the a0-3 (also t0-9) registers may be destoyed by first call, so I think FD is invalid in the second call.
 
Thank you for your reply @__YAS__ .

The error occurs immediately after executing sceIoRead. Which returns v0 = 80020323 (BADF). So it never gets to the point of looping. So to answer your statement “a0-3 may be destroyed by the first call”, that is correct, all those registers will be turned to "deadbeef". But the reading itself already failed once it tried to execute the first time, not the second time (which never happens). So yes, any call to the syscalls will destroy any value in the registers, and I then have to fill them again (by saving the values prior to using any syscall). But the second loop never executes since the first call already errored out. However, you are right that I need to save the registers.

I have tried by reading just 64 bytes from the file, no loop, which also results in 80020323 from any form of reading (async/sync). Even trying to read files that the game already reads normally during startup (the original script file) also fails within my function.

From what I can gather, yes, a1 seems to be unused with Lseek when I checked with how the game originally operates with it. The a1 register is not “empty” or so when it is called within the original functions, but the value is not used.

This is what I was told by both documentation and chatGPT:
a0 = FD, a1 = offset(low), a2 = offset(high), a3 = SEEK_END/SET

But that returned errors, and when I checked how the game uses Lseek it looked more like this:
a0 = FD, a1 = *not an offset*, a2 = offset(low), a3 = offset(high), t0 = SEEK_END/SET

Which when I began using it returned “correct” behavior and I was able to seek my files.

I have not seen Lseek32, but that could be a useful one to check, but right now I am using the syscall trampolines that the game originally uses and Lseek32 seems to not have been added as a trampoline function in the game's code.

The duplicated JALR nop calls have been fixed, I saw that after posting (they were giving me some weird behavior with the branching later on, so they stood out).

I already use a way to replace the script externally, but with using a modified version of PPSSPP to do it. But the goal was to make the patch more suited towards running on an actual PSP without much pre-requirements (simply extract the eboot, modify it with the patch function and add the replacement script).
 
The error occurs immediately after executing sceIoRead.
I have tried by reading just 64 bytes from the file, no loop, which also results in 80020323 from any form of reading (async/sync). Even trying to read files that the game already reads normally during startup (the original script file) also fails within my function.
Are those codes without 'beqz $t1, r1_done' at the beginning of the loop?
Also did you try that by composition of only open and read, close functions simply?

I don't know how PPSSPP grasped where sceIoRead was surely called from by the address in the register, but I have thought there is a possibility that some kind of File I/O function is called and returned error(BADF) by the duplicated JALR with destroyed an address and args instead of JALR for sceIoRead call that will never be executed when uninitialized t1 == 0(undefined or destroyed).

In the case of yet other file?
But that returned errors, and when I checked how the game uses Lseek it looked more like this:
a0 = FD, a1 = *not an offset*, a2 = offset(low), a3 = offset(high), t0 = SEEK_END/SET
Oh, I see.
I have not seen Lseek32, but that could be a useful one to check, but right now I am using the syscall trampolines that the game originally uses and Lseek32 seems to not have been added as a trampoline function in the game's code.
That's right, I have forgot APIs can't be used simply that weren't used by the original codes.

The modified script is read by your codes, so there is the way to insert previously the file size in the first 4 bytes of the file to eliminate the need for a complex Lseek too.
In other words, the file format is up to you.
But the goal was to make the patch more suited towards running on an actual PSP without much pre-requirements (simply extract the eboot, modify it with the patch function and add the replacement script).
What do you think about reading the modified script from MS? Did you try it?
If successful, it will improve compatibility and allow support for untouched ISOs and UMDs by loading indilectly and executing, dynamic patching EBOOT from your homebrew.
 
Last edited by __YAS__,
Yes, I have other tries without using a loop. For example, I have another file I also wanted to read that is just around 266 bytes, which would overwrite text in the menu.

That looks like this in an older test code:

lui $a0, 0x0889
ori $a0, $a0, 0x1048 # disc0:/PSP_GAME/USRDIR/patch/menu_text.bin
li $a1, 0 # O_RDONLY
li $a2, 0x01FF # permissions, all.
.word 0x0E21FB6B # jal sceIoOpen
nop
move $t2, $v0
move $a0, $t2 # FD
li $a1, 0x0888CC94 # buf
li $a2, 266 # length
.word 0x0E21FB5D # jal sceIoRead
nop
move $a0, $t2
.word 0x0E21FB63 # jal sceIoClose
nop

I also tried moving the files to the memory stick, and instead using the filepaths: “ms0:/PSP/GAME/patch/*” for the files. Which also led to the same result, able to open the files but unable to read.

From what I could gather of the game’s process of reading files, it looks similar to this:

1. Open file with IoOpen, change async priority
2. Perform an async read of the opened file to a specific temporary memory location.
3. Decompress the read data, moving it to another location, or simply just moving the read data without decompressing. Nothing is stored permanently at this temporary location, it gets overwritten each time a file is opened and read.

Since the temporary memory location is overwritten with each file’s contents when read, I also tried using that location instead when reading my own files, but it also returned the error BADF despite a valid FD. The error 80020323, BADF, should imply an invalid FD but since I can use Lseek by using that FD means it actually is valid?

There is the possibility of simply changing the original script file, to my modified one (replacing it in the ISO for example). However, the modified version is not compressed with the original compression (LZSS). I tried a lot to manage compressing a decompressed but unedited version of the script back into its original state but never achieved any success. The game “will” load the decompressed script, but it tries to decompress it which results in fragmented bytes in the scripts location in memory. Some flag bytes may pass, but a lot is missing. I could “nop” out the decompression routine, but it is used by almost all the files which leads to a lot of issues down the line if I modify it. Which is why it would be easier if the game read my edited script file later on to overwrite the original script in memory.

Perhaps extending the original read routines could work, having it branch at the start of the decompressing routine. Then skip out on decompressing if the FD is what the script gets assigned (seems to be 6? each time), however I assume that this would be much more prone to issues than reading in my patched function, if it is possible.

don't know how PPSSPP grasped where sceIoRead was surely called from by the address in the register, but I have thought there is a possibility that some kind of File I/O function is called and returned error(BADF) by the duplicated JALR with destroyed an address and args instead of JALR for sceIoRead call that will never be executed when uninitialized t1 == 0(undefined or destroyed).
I wonder if something like this could be the issue, what I use to call sceIoRead is a trampoline function that the game uses originally, which when read in the CPU debugger of PPSSPP is named: zz_sceIoRead, which then calls sceIoRead.

This trampoline function simply calls sceIoRead then jumps back to RA and lies at the offset: 0x0887ED74 for me.
zz_sceIoRead: jr ra
syscall sceIoRead

But, the trampoline function does not execute anything else than just sceIoRead then jumping back to RA, so it should not impact other things.

The modified script is read by your codes, so there is the way to insert previously the file size in the first 4 bytes of the file to eliminate the need for a complex Lseek too.
In other words, the file format is up to you.

This is a good idea, if the read issue is solved then I might use this.
 
Last edited by chisel,
Perhaps extending the original read routines could work, having it branch at the start of the decompressing routine.
Certainly, such a method would work fine even if a unique lock was applied, since it would read the file under the same circumstances as the original.
For example, I think it is better to use a more reliable identifier than an FD like a file name.
This trampoline function simply calls sceIoRead then jumps back to RA and lies at the offset: 0x0887ED74 for me.
If I remember correctly, the actual PSP is the same too.
The dynamic link is managed by each STUB ID(function ID), and IDs in memory are overwritten jump table after the executable file was loaded.
And the app calls jump table by JAL.
That looks like this in an older test code:
The code shown is very simple.
If that works wrong then unfortunately I don't see what's going on from the information you provided.
I have created the file system driver like loopback devices of linux and the plugin to hook dynamically into File I/O, that run on actual PSP, but even in those situations I have never been unable to read files.

I'm sorry I couldn't help you.
If I think of anything else I'll let you know.
 
Certainly, such a method would work fine even if a unique lock was applied, since it would read the file under the same circumstances as the original.
For example, I think it is better to use a more reliable identifier than an FD like a file name.
I think this is what I will go with if all else fails, as I believe it to be the only method that will most likely work. As the original script file can be opened up by the game's own reading and is then later used within the decompression routine. I think that hijacking this process and skip decompressing will work but I have a lot of things to take care of in that regard, as to not break any behavior of the game's following code.

The code shown is very simple.
If that works wrong then unfortunately I don't see what's going on from the information you provided.
I have created the file system driver like loopback devices of linux and the plugin to hook dynamically into File I/O, that run on actual PSP, but even in those situations I have never been unable to read files.
Is the location where I execute my function one of the possible issues? Right now, I use 2 functions in my patch. The first is executed at start-up of the game and overwrites the menu choice for installing the game on the memory stick to calling my other script loader instead. When the player presses the install menu choice, the area they are in within the code is inside of a routine (similar to a hub, where it loops over and over again to check certain conditions). It will therefore execute some other routines (waiting until the game has been idle enough to show the intro movie, etc).

When I press the menu choice for installation it will execute some routines that belong to the installation setup, but then branches in the middle of it to my function, before any installation is performed. I don't know how well the images will capture any of this behavior and make it more clear, but it is possible to see where I have edited the code to branch over to my own function that resides at offset 0x08890E60.

I have tried to move the execution of this loader script to the very beginning of the EBOOT, so it will begin by executing my loading script before performing anything else. This did not change anything however, and reads continued to fail.

Is there some prerequisites towards reading a file that I am missing?

I tried to use a thread and then async read within it, which should've mimicked what the game originally does (from what I can gather, I/O operations are handled in a separate thread from other logic, so it does not run in the main thread). What I got from this was that IoReadAsync returned 0x0 in v0, which if I interpret it correctly meant that it was queued successfully. I then tried to use IoWaitAsync in order to execute the queued async request, but that resulted in v0 = 0x0 too, which states that 0 bytes were read.
I'm sorry I couldn't help you.
If I think of anything else I'll let you know.

No problem, thanks for trying to help.

This issue seems to stem from something other than just the reading itself, I must be missing something. Despite the error stating BADF (80020323) I think something else might be erroring out, but I receieve this error as a generic one. I thought first that the memory location I tried to read my file towards was perhaps regarded as "read only" or something similar, thus could not be overwritten. However, since even by using a temporary location the game already uses repeatedly during normal execution for temporary reads/storage also failed means that the location itself (the Buf argument) was not the issue.
 

Attachments

  • 1.png
    1.png
    3.7 KB · Views: 30
  • 2.png
    2.png
    2.8 KB · Views: 35
  • 3.png
    3.png
    12.4 KB · Views: 35
  • 4.png
    4.png
    1.2 KB · Views: 32

Site & Scene News

Popular threads in this forum