skyfloogle / red-viper

A Virtual Boy emulator for the 3DS
764 stars 17 forks source link

True 50 Hz support #46

Closed vaguerant closed 3 months ago

vaguerant commented 3 months ago

Consider this a "nice to have" backburner issue.

Apparently it is technically possible to fudge the refresh rate of the 3DS screen. This is done in the (closed-source) emulator ZXDS, which emulates the ZX Spectrum--a computer that was really only successful in PAL50 territories, where most of the software is designed around 50 Hz. As far as I know, there are no open-source implementations of the refresh delay implementation used by ZXDS, so unfortunately there's not much I can point to as far as how this can be achieved.

The ZXDS author did write down a lot of their process when developing the original DS version of their emulator which you can read here, but that page has not been updated since the emulator was ported to run on the 3DS. Forcing the original DS screen into refreshing at 50 Hz involved (ab)using the VCOUNT register, which is able to delay refreshes for the purpose of synchronizing wireless multiplayer games. I don't know whether the 3DS has something equivalent.

The ZXDS changelog is behind a Patreon subscription, but the version 2.0.1 changelog can be found online and implies there are at least two possible methods of delaying refreshes, one of which was problematic on certain 3DS models:

Changelog:

  • Fix top screen issues of 2DS and 3DS XL models by slowing the LCD display differently. Thanks Nige.
  • Disabled turning off of bottom screen backlight on 2DS as for some reason it affects both screens.
  • The optional ZL and ZR buttons are now bound to quick load and quick save actions, respectively.
  • Considerably increased speed of scanning directories.
  • Few more subtle changes (async file flush, display speed adjustment only at normal speed).

Speaking for myself, I really can't tell the difference while playing games, 50 FPS displaying with duped frames at 60 Hz is fine, especially considering the more advanced games like Red Alarm don't run at a full 50 anyway. Still, it would probably be even better to match the refresh rate to the original console for greater accuracy.

EDIT: Talking to asiekierka, I see that this has already been shared with you in much greater detail and that there is an open-source implementation in atari800-3ds. For the benefit of anybody else who happens to find this, I think asiekierka/atari800-3ds@133bb732f86c52a6ce65ba2111cc92157c13c836 is the commit where the magic happened.

profi200 commented 3 months ago

I would personally recommend using the V-total reg for this. V-total is PDC regs + 0x24. I had heard that a few people had issues with H-total. Here is another example. In this case i'm adjusting the value every frame to perfectly synchronize with GBA output. https://github.com/profi200/libn3ds/blob/master/source/arm11/drivers/lgyfb.c#L42

riggles1 commented 3 months ago

The holy grail! That would be amazing. I thought it was impossible on the 3DS hardware. VB doomed to be locked to 50fps on a 60Hz refresh.

Framepacing in games like Jack Bros is stuttery compared to when I play it on a 50Hz panel. Galactic Pinball is even more stuttery compared to how smooth it should look. Wario plays fine enough, Clash even more so with the lack of scrolling backgrounds, it totally depends on scrolling background and animation speeds in the game if it'll look okay on a refresh mismatch.

But proper 50Hz would be so nice, it's a lot more apparent if you're already used to the buttery smooth motion of native refresh. It's one of the, once you notice you never can unnotice, type of things.

profi200 commented 3 months ago

Also see the VTotal reg below for a formula to calculate the refresh rate with the current settings: https://www.3dbrew.org/wiki/GPU/External_Registers#LCD_Source_Framebuffer_Setup

skyfloogle commented 3 months ago

@asiekierka told me about this a couple days after the initial release. I tried implementing it, but couldn't get it to work: it still ran at 60Hz, and exiting the emulator crashed my 3DS. I'd love to see this working though! If any of you wanna see if you can get it working you're more than welcome to, otherwise I'll get to it whenever I get to it.

profi200 commented 3 months ago

Does the emu synchronize with VBlank or with audio? If audio then of course it will still run at whatever the audio rate is.

And a little tip. With the whole apt hook cookie stuff shown in the commit vaguerant linked i would just backup the whole register instead of assuming what the register was set to on app launch.

edit: Here comes a simple example that shows how it works. If you set VTotal to about 2476 you get 10 fps. It's ridiculous to see how slow HOME menu runs. The fps printed also updates very slow and you can just about see how the LCD draws the pixels. However something often resets VTotal so it will go back to normal.

#include <stdio.h>
#include <stdint.h>
#include <inttypes.h>
#include <stdlib.h>
#include <string.h>
#include <3ds.h>

int main(int argc, char* argv[])
{
    gfxInitDefault();
    consoleInit(GFX_TOP, NULL);

    u32 vtotal;
    Result res = GSPGPU_ReadHWRegs(0x400424, &vtotal, 4);
    printf("VTotal test: 0x%" PRIX32 "\nUp: + 1, Left: + 10, Down: - 1, Right: - 10\n", res);

    u64 startTicks = 0;
    u32 frameCount = 0;
    bool update = true;
    while (aptMainLoop())
    {
        if(frameCount == 0) startTicks = svcGetSystemTick();
        gspWaitForVBlank();
        frameCount++;
        if(frameCount == 60)
        {
            const u64 ticks = svcGetSystemTick() - startTicks;
            printf("\x1b[5;1H%f fps ", (double)268111856 * 60u / ticks);
            frameCount = 0;
        }

        gfxFlushBuffers();
        gfxSwapBuffers();
        hidScanInput();

        // Your code goes here
        u32 kDown = hidKeysDown();
        if (kDown & KEY_START)
            break; // break in order to return to hbmenu
        else if(kDown & KEY_DUP)
        {
            vtotal++;
            update = true;
        }
        else if(kDown & KEY_DLEFT)
        {
            vtotal += 10;
            update = true;
        }
        else if(kDown & KEY_DDOWN)
        {
            vtotal--;
            update = true;
        }
        else if(kDown & KEY_DRIGHT)
        {
            vtotal -= 10;
            update = true;
        }

        if(update)
        {
            update = false;
            vtotal &= 0xFFFu; // VTotal is 12 bits.
            GSPGPU_WriteHWRegs(0x400424, &vtotal, 4);
            printf("\x1b[4;1HVTotal: %" PRIu32 "    ", vtotal);
        }
    }

    gfxExit();
    return 0;
}
skyfloogle commented 3 months ago

It's currently on a hardware 20ms timer, but when I tested this I largely copied the atari800 implementation, including syncing on vblank and storing the initial register value. While I haven't gotten around to testing your snippet yet, I did try the atari800 emulator at the time, and it seemed to work.

profi200 commented 3 months ago

As mentioned above you probably also have to backup/restore the old value each time the app is going into background. Keywords sleep mode, HOME menu, app close, power button press. I assume this can all be done via apt hooks. Most of the software expects 59.8 fps but i don't think it would cause crashes anywhere.

I don't know the exact frame rate of the Virtual Boy. It's unlikely that you will get a perfect match with VTotal alone meaning you could always try the approach open_agb_firm uses picking the 2 VTotal values that give the closest fps to VB and then switching between them on a frame by frame basis. This is absolutely invisible to your eyes and never failed me so far.

vaguerant commented 3 months ago

My backlight PR #48 does some APT hook stuff if you need an example that already works in Red Viper.

skyfloogle commented 3 months ago

Found out why things started glitching out in my previous attempt. All the existing calculations work when 3D is disabled, in which case the resting VTotal is 413. When 3D is enabled (i.e. gfxSet3D(true), not the slider), the resting VTotal doubles to 827 (the value actually used is VTotal+1 which explains the off-by-one). This only seems to happen after rendering a frame in citro3d and waiting for vblank. I need to verify this on other 3DS models (especially 2DS models) before implementing it, but this is an important find.

profi200 commented 3 months ago

On 2DS 3D and wide modes don't work at all. They will display incorrectly.

Also yeah, that value doubles. I should have mentioned it: https://github.com/profi200/libn3ds/blob/master/include/arm11/drivers/pdc_presets.h#L109

skyfloogle commented 3 months ago

I wonder how this interacts with capture cards. I see the most recent commit in libn3ds relates to those, but that at least is still roughly 60Hz.

skyfloogle commented 3 months ago

Got someone to test this on a 2DS and made interesting observations:

profi200 commented 3 months ago

From what i gather the capture cards use a lazy approach by synchronizing to one LCD only and then capturing both frames at the same time. The way i did it previously made the LCDs not run at the same speed so they were not in sync. Certain capture cards didn't like that and showed garbage frames.

The HOME menu slowdown you mentioned comes from HOME menu not handling out of sync LCDs correctly i have been told. It will get VBlank events at entirely different times.

Leaving the v-total sets unchanged from the regular 3DS version has the game run at full speed but with awful screen tearing, and the home menu runs very slowly

That's because setting v-total higher makes it slower. And gsp likely refuses to turn on the pixel doubling mode on 2DS. So you effectively only slow down the LCD.

SonoSooS commented 3 months ago

I'll try to clear up any confusion.

2DS is the only known 3DS with 1:1 pixels (as opposed to the 2:1 pixels, where two pixels on top of eachother form a 1:1 pseudo-pixel in 2D mode), so that's why the image appears stretched when 3D is enabled. In fact, it's non-2DS that stretches the image, so it doesn't appear squished.

It does actually matter when and how you change VTotal. Not only do SDK programs expect both VSync to happen at the same time, but 2DS screen actually glitches out if the two screens are way too desynchronized. Not only that, but when the VTotal write happens also matters, as I heard that IPS screens are notoriously sensitive for timing "glitches".
Preferably you would wait until the sync pulse is done, and then set VTotal. This would ensure that you have the most amount of IPC latency available, and won't accidentally race something nasty. The easiest way would be to check if previous VCount is bigger than current VCount (so it overflowed to 0 or so, but due to IPC latency you might not even see the value 0, just that previous > current).
Note that I said VCount, not VTotal. VCount is like LY in Game Boy, or VCOUNT on DS, except here it doesn't seem to be writable. As for 3D switching, wait for gsp to switch 3D mode, then switch the timings using the method above.

DO NOT use clock doubling on 2DS, even if you manage to somehow bypass gsp in this regard. If you really need fake 3D mode, you can use AB interlacing, use a stride size that is double of the real stide size, adjust the 2nd buffer's framebuffer pointer by one real stride's worth, and you've got 2D mode interlacing. So basically what gsp does in 3D mode, except we have to do the stride and pointer nonsense, so it works on 2DS in particular. Although not sure you want to do this, but this is an option.

If there are any questions, please ping me with the questions, I'm more than happy to answer 😃

skyfloogle commented 2 months ago

I implemented the thing where you wait for VCount to decrease, but it seems to break when you press the power button and return to home menu. For some reason, when closing the software in this way, VCount never changes. I made it abort if it takes longer than 20ms as a workaround, but is there a better way to deal with that?

SonoSooS commented 2 months ago

Not sure why your aptHookCookie doesn't work as expected (source/3ds/gui_hard.c:aptBacklight), there is probably a race condition somewhere?

As for LCDs getting corrupted, there should be a way to force gsp to re-synchronize the LCDs.
You should also wait for both top and bottom to have VCount overflow (sadly the logic for that is more difficult), and then write both regs. As it currently stands, it could be possible that the bottom screen's VCount did not overflow yet (pretty sure gsp turns on the top screen first), and that could also cause corruption.
It could be possible that you measure which LCD got turned on 2nd (pretty sure it's the bottom one), and synchronize to that instead. Optionally, you could just synchronize to the bottom LCD instead, and hope that the bottom one is a bit lagging behind.

Alternatively, you could synchronize twice after toggling VTotal, just for good luck, to avoid any race conditions possible in gsp and such.

skyfloogle commented 2 months ago

Found out that if I write to VTotal lazily (i.e. don't write when the state doesn't change), the previously mentioned bug is fixed. I will do the bottom screen thing though, it makes sense.

nealt commented 1 month ago

Changing the vcount wait to the other screen doesn't seem to have made any difference. Screens still get out of sync sometimes. Is gspWaitForVBlank() not sufficient for what you're trying to accomplish? Simply removing the vcount wait makes it work 100% of the time for me.

With that fixed, lazy vtotal write isn't really necessary either (though harmless).

I'd suggest getting rid of both things. In other words, waitForVblank was all you needed to do.

skyfloogle commented 1 month ago

Thanks for getting in touch! I'll probably keep the lazy write, but if removing the vcount wait helps I'll do that.

profi200 commented 1 month ago

Is this about capture cards? Because i had to make changes recently as well. None of the capture cards on the market handle the situation well when the LCDs don't stay synchronized down to the exact same output cycle. The solution for me was to set the LCD timings of both LCDs at the same time.