Explorar o código

Video playback, movie cameras, and audio fixes.

Videos
- The installer converts every FF7 movie with ffmpeg to a folder of JPEG
  frames, an ogg with the sound, and movie.xml. It runs after extracting
  the videos from a game disk and in the normal install.
- It also copies the camera of each movie from moviecam.lgp (camera.cam).
- New MoviePlayer and MovieState draw the frames at 15 fps behind the models
  and follow game speed. A movie with a camera takes the place of the
  background tiles; the models are drawn over it with the camera of each frame
  and hidden in frames without one. A movie without a camera covers the field.
- The movie opcodes PMVIE, MOVIE and MVIEF generate movie_player:set(name,
  scale), play() and get_frame(). The movie names come from FF7.EXE. play()
  waits for the end of the movie in the script that calls it.
- The sound plays in its own audio channel.

Audio
- Fix a crash and music stutter: a stopped player kept the id of a source
  that OpenAL could give to another player, so it freed its decoder twice and
  consumed the buffers of the other one.
Iñigo Valentin hai 1 día
pai
achega
3b6d55ed8c

+ 3 - 0
src/CMakeLists.txt

@@ -601,6 +601,9 @@ set(VGEARS_SOURCE_FILES
     core/Background2DAnimation.cpp
     core/Background2DAnimation.cpp
     core/Background2D.cpp
     core/Background2D.cpp
     core/ScreenFade.cpp
     core/ScreenFade.cpp
+    core/MovieCameraFrame.cpp
+    core/MoviePlayer.cpp
+    core/MovieState.cpp
     core/ScreenFadeState.cpp
     core/ScreenFadeState.cpp
     core/BattleManager.cpp
     core/BattleManager.cpp
     core/CameraManager.cpp
     core/CameraManager.cpp

+ 24 - 2
src/core/AudioManager.cpp

@@ -34,7 +34,7 @@ int AudioManager::channel_buffer_size_ = 96 * 1024;
 
 
 AudioManager::AudioManager():
 AudioManager::AudioManager():
   initialized_(false), thread_continue_(true), update_mutex_(), music_(&update_mutex_),
   initialized_(false), thread_continue_(true), update_mutex_(), music_(&update_mutex_),
-  battle_music_(&update_mutex_), fx_(&update_mutex_)
+  battle_music_(&update_mutex_), fx_(&update_mutex_), movie_(&update_mutex_)
 {
 {
     al_device_ = alcOpenDevice(nullptr);
     al_device_ = alcOpenDevice(nullptr);
     if (al_device_ != nullptr){
     if (al_device_ != nullptr){
@@ -143,6 +143,7 @@ void AudioManager::MusicPlay(const Ogre::String& name){
         }
         }
         else{
         else{
             battle_music_.Stop();
             battle_music_.Stop();
+            LOG_TRIVIAL("Music '" + name + "' plays '" + music->file + "'.");
             music_.SetLoop(music->loop);
             music_.SetLoop(music->loop);
             music_.Play(music->file);
             music_.Play(music->file);
         }
         }
@@ -190,6 +191,19 @@ void AudioManager::SoundPlay(const Ogre::String& name){
     }
     }
 }
 }
 
 
+void AudioManager::MoviePlay(const Ogre::String& file){
+    if (!initialized_) return;
+    boost::recursive_mutex::scoped_lock lock(update_mutex_);
+    movie_.SetLoop(-1);
+    movie_.Play(file);
+}
+
+void AudioManager::MovieStop(){
+    if (!initialized_) return;
+    boost::recursive_mutex::scoped_lock lock(update_mutex_);
+    movie_.Stop();
+}
+
 void AudioManager::ScriptPlaySound(const char* name){
 void AudioManager::ScriptPlaySound(const char* name){
     const Ogre::String name_str = Ogre::String(name);
     const Ogre::String name_str = Ogre::String(name);
     SoundPlay(name_str);
     SoundPlay(name_str);
@@ -282,12 +296,14 @@ void AudioManager::UpdateField(){
     boost::recursive_mutex::scoped_lock lock(update_mutex_);
     boost::recursive_mutex::scoped_lock lock(update_mutex_);
     music_.Update();
     music_.Update();
     fx_.Update();
     fx_.Update();
+    movie_.Update();
 }
 }
 
 
 void AudioManager::UpdateBattle(){
 void AudioManager::UpdateBattle(){
     boost::recursive_mutex::scoped_lock lock(update_mutex_);
     boost::recursive_mutex::scoped_lock lock(update_mutex_);
     battle_music_.Update();
     battle_music_.Update();
     fx_.Update();
     fx_.Update();
+    movie_.Update();
 }
 }
 
 
 void AudioManager::UpdateWorld(){UpdateField();}
 void AudioManager::UpdateWorld(){UpdateField();}
@@ -378,7 +394,10 @@ void AudioManager::Player::Play(const Ogre::String &file){
 
 
 void AudioManager::Player::Stop(){
 void AudioManager::Player::Stop(){
     boost::recursive_mutex::scoped_lock lock(*update_mutex_);
     boost::recursive_mutex::scoped_lock lock(*update_mutex_);
-    if (!alIsSource(source_)) return;
+    // Nothing to stop if there is no track. The id of a source that was deleted can be given to
+    // another player, so checking if it is a source is not enough: it would delete the source of
+    // the other player and free the vorbis file a second time.
+    if (file_.empty() || !alIsSource(source_)) return;
     // Stop source
     // Stop source
     alSourceStop(source_);
     alSourceStop(source_);
     // Get source buffers
     // Get source buffers
@@ -396,6 +415,7 @@ void AudioManager::Player::Stop(){
     ov_clear(&vorbis_file_);
     ov_clear(&vorbis_file_);
     stream_finished_ = false;
     stream_finished_ = false;
     file_ = "";
     file_ = "";
+    source_ = 0;
 }
 }
 
 
 void AudioManager::Player::SetLoop(const float loop){
 void AudioManager::Player::SetLoop(const float loop){
@@ -404,6 +424,8 @@ void AudioManager::Player::SetLoop(const float loop){
 }
 }
 
 
 void AudioManager::Player::Update(){
 void AudioManager::Player::Update(){
+    // A player with no track has no source. Its old id may belong to another player by now.
+    if (file_.empty()) return;
     // Try to fill processed buffers
     // Try to fill processed buffers
     int processed = 0;
     int processed = 0;
     alGetSourcei(source_, AL_BUFFERS_PROCESSED, &processed);
     alGetSourcei(source_, AL_BUFFERS_PROCESSED, &processed);

+ 17 - 0
src/core/AudioManager.h

@@ -212,6 +212,18 @@ class AudioManager : public Manager, public Ogre::Singleton<AudioManager>{
          */
          */
         void SoundPlay(const Ogre::String& name);
         void SoundPlay(const Ogre::String& name);
 
 
+        /**
+         * Plays the sound of a movie, in its own channel.
+         *
+         * @param[in] file Path to the ogg file.
+         */
+        void MoviePlay(const Ogre::String& file);
+
+        /**
+         * Stops the sound of the movie.
+         */
+        void MovieStop();
+
         /**
         /**
          * Stops the currently playing music.
          * Stops the currently playing music.
          *
          *
@@ -510,6 +522,11 @@ class AudioManager : public Manager, public Ogre::Singleton<AudioManager>{
          */
          */
         AudioManager::Player fx_;
         AudioManager::Player fx_;
 
 
+        /**
+         * Player of the sound of movies.
+         */
+        AudioManager::Player movie_;
+
         /**
         /**
          * List of music.
          * List of music.
          */
          */

+ 3 - 9
src/core/Background2D.cpp

@@ -494,15 +494,7 @@ void Background2D::ScriptScrollToPosition(
 ){
 ){
     LOG_TRIVIAL(
     LOG_TRIVIAL(
       "Background2d set scroll to position '"
       "Background2d set scroll to position '"
-      + Ogre::StringConverter::toString(Ogre::Vector2(x, y)) + "', current '"
-      + Ogre::StringConverter::toString(position_) + "', player would be at '"
-      + (EntityManager::getSingleton().ScriptGetPlayerEntity() != nullptr
-        ? Ogre::StringConverter::toString(
-            GetEntityScroll(EntityManager::getSingleton().ScriptGetPlayerEntity())
-          )
-        : Ogre::String("none"))
-      + "', range " + Ogre::StringConverter::toString(range_.getMinimum()) + " "
-      + Ogre::StringConverter::toString(range_.getMaximum()) + "."
+      + Ogre::StringConverter::toString(Ogre::Vector2(x, y)) + "'."
     );
     );
     scroll_entity_ = nullptr;
     scroll_entity_ = nullptr;
     if (type == Background2D::NONE || seconds <= 0){
     if (type == Background2D::NONE || seconds <= 0){
@@ -888,6 +880,8 @@ void Background2D::renderQueueEnded(
   Ogre::uint8 queue_group_id, const Ogre::String& invocation, bool& repeat_this_invocation
   Ogre::uint8 queue_group_id, const Ogre::String& invocation, bool& repeat_this_invocation
 ){
 ){
     if (cv_show_background2d.GetB() == false) return;
     if (cv_show_background2d.GetB() == false) return;
+    // The movie takes the place of the background, and the models are drawn over it.
+    if (EntityManager::getSingleton().GetMoviePlayer()->IsDrawnBehind()) return;
     if (queue_group_id == Ogre::RENDER_QUEUE_MAIN){
     if (queue_group_id == Ogre::RENDER_QUEUE_MAIN){
         Ogre::GpuProgramParametersPtr rs_params = render_system_->getFixedFunctionParams(
         Ogre::GpuProgramParametersPtr rs_params = render_system_->getFixedFunctionParams(
           Ogre::TVC_NONE, Ogre::FOG_NONE
           Ogre::TVC_NONE, Ogre::FOG_NONE

+ 21 - 0
src/core/CameraManager.cpp

@@ -249,8 +249,29 @@ void CameraManager::EndBattleCamera(){
       << camera_->getOrientation().y << ", " << camera_->getOrientation().z << std::endl;*/
       << camera_->getOrientation().y << ", " << camera_->getOrientation().z << std::endl;*/
 }
 }
 
 
+void CameraManager::SetMovieCamera(
+  const Ogre::Vector3& position, const Ogre::Quaternion& orientation, const Ogre::Radian& fov
+){
+    movie_camera_ = true;
+    camera_->setPosition(position);
+    camera_->setOrientation(orientation);
+    camera_->setFOVy(fov);
+    // The movie fills the screen: it doesn't scroll.
+    camera_->resetFrustumExtents();
+}
+
+void CameraManager::EndMovieCamera(){
+    if (!movie_camera_) return;
+    movie_camera_ = false;
+    camera_->setPosition(d2_position_);
+    camera_->setOrientation(d2_orientation_);
+    camera_->setFOVy(d2_fov_);
+    Set2DScroll(d2_scroll_);
+}
+
 void CameraManager::Set2DScroll(const Ogre::Vector2& position){
 void CameraManager::Set2DScroll(const Ogre::Vector2& position){
     d2_scroll_ = position;
     d2_scroll_ = position;
+    if (movie_camera_) return;
     if(camera_free_ == true) return;
     if(camera_free_ == true) return;
     float width = static_cast<float>(viewport_->getActualWidth());
     float width = static_cast<float>(viewport_->getActualWidth());
     float height = static_cast<float>(viewport_->getActualHeight());
     float height = static_cast<float>(viewport_->getActualHeight());

+ 23 - 0
src/core/CameraManager.h

@@ -108,6 +108,24 @@ class CameraManager : public Manager, public Ogre::Singleton<CameraManager>{
           const Ogre::Radian fov
           const Ogre::Radian fov
         );
         );
 
 
+        /**
+         * Places the camera where the camera of a movie is, to draw the models over it. The field
+         * camera is kept, and the scroll of the background doesn't move the camera until
+         * {@see EndMovieCamera}.
+         *
+         * @param[in] position Camera coordinates.
+         * @param[in] orientation Camera rotation.
+         * @param[in] fov Vertical field of view.
+         */
+        void SetMovieCamera(
+          const Ogre::Vector3& position, const Ogre::Quaternion& orientation, const Ogre::Radian& fov
+        );
+
+        /**
+         * Puts the camera of the field back, with its scroll.
+         */
+        void EndMovieCamera();
+
         /**
         /**
          * Starts the battle camera
          * Starts the battle camera
          *
          *
@@ -311,5 +329,10 @@ class CameraManager : public Manager, public Ogre::Singleton<CameraManager>{
          * The camera scroll.
          * The camera scroll.
          */
          */
         Ogre::Vector2 d2_scroll_;
         Ogre::Vector2 d2_scroll_;
+
+        /**
+         * Whether the camera is the one of a movie.
+         */
+        bool movie_camera_ = false;
 };
 };
 
 

+ 5 - 0
src/core/EntityManager.cpp

@@ -323,6 +323,9 @@ void EntityManager::UpdateField(){
     background_2d_.Update();
     background_2d_.Update();
     // This can start a battle and change the module, so it's the last thing to do.
     // This can start a battle and change the module, so it's the last thing to do.
     UpdateEncounters(walked);
     UpdateEncounters(walked);
+    // The movie follows the speed of the game, so the scenes that are timed with it stay in sync.
+    // Its sound doesn't, so at any other speed they drift apart.
+    movie_player_.Update(Timer::getSingleton().GetGameTimeDelta());
     screen_fade_.Update(Timer::getSingleton().GetGameTimeDelta());
     screen_fade_.Update(Timer::getSingleton().GetGameTimeDelta());
 }
 }
 
 
@@ -598,6 +601,8 @@ Background2D* EntityManager::GetBackground2D(){return &background_2d_;}
 
 
 ScreenFade* EntityManager::GetScreenFade(){return &screen_fade_;}
 ScreenFade* EntityManager::GetScreenFade(){return &screen_fade_;}
 
 
+MoviePlayer* EntityManager::GetMoviePlayer(){return &movie_player_;}
+
 void EntityManager::AddEntity(
 void EntityManager::AddEntity(
   const Ogre::String& name, const Ogre::String& file_name,
   const Ogre::String& name, const Ogre::String& file_name,
   const Ogre::Vector3& position, const Ogre::Degree& rotation, int index
   const Ogre::Vector3& position, const Ogre::Degree& rotation, int index

+ 13 - 0
src/core/EntityManager.h

@@ -25,6 +25,7 @@
 #include "EntityTrigger.h"
 #include "EntityTrigger.h"
 #include "Event.h"
 #include "Event.h"
 #include "Manager.h"
 #include "Manager.h"
+#include "MoviePlayer.h"
 #include "ScreenFade.h"
 #include "ScreenFade.h"
 #include "Walkmesh.h"
 #include "Walkmesh.h"
 
 
@@ -168,6 +169,13 @@ class EntityManager : public Manager, public Ogre::Singleton<EntityManager>{
          */
          */
         ScreenFade* GetScreenFade();
         ScreenFade* GetScreenFade();
 
 
+        /**
+         * Retrieves the movie player.
+         *
+         * @return The movie player.
+         */
+        MoviePlayer* GetMoviePlayer();
+
         /**
         /**
          * Adds an entity to the manager.
          * Adds an entity to the manager.
          *
          *
@@ -770,6 +778,11 @@ class EntityManager : public Manager, public Ogre::Singleton<EntityManager>{
          */
          */
         Background2D background_2d_;
         Background2D background_2d_;
 
 
+        /**
+         * The movie player. It has to be created before the screen fade, which is drawn over it.
+         */
+        MoviePlayer movie_player_;
+
         /**
         /**
          * The coloured fade of the screen. It has to be created after the background, because the
          * The coloured fade of the screen. It has to be created after the background, because the
          * fade must be drawn over it.
          * fade must be drawn over it.

+ 65 - 0
src/core/MovieCameraFrame.cpp

@@ -0,0 +1,65 @@
+/*
+ * Copyright (C) 2022 The V-Gears Team
+ *
+ * This file is part of V-Gears
+ *
+ * V-Gears is free software: you can redistribute it and/or modify it under
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation, version 3.0 (GPLv3) of the License.
+ *
+ * V-Gears is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ */
+
+#include <algorithm>
+#include <cstring>
+#include <OgreMath.h>
+#include <OgreMatrix3.h>
+#include "core/MovieCameraFrame.h"
+
+namespace{
+
+    // Height of the screen the focal length refers to.
+    const float SCREEN_HEIGHT = 240.0f;
+
+    int16_t ReadS16(const uint8_t* data, const size_t offset){
+        int16_t value;
+        std::memcpy(&value, data + offset, sizeof(value));
+        return value;
+    }
+
+    int32_t ReadS32(const uint8_t* data, const size_t offset){
+        int32_t value;
+        std::memcpy(&value, data + offset, sizeof(value));
+        return value;
+    }
+}
+
+MovieCameraFrame MovieCameraFrame::Parse(const uint8_t* data, const float field_scale){
+    MovieCameraFrame frame;
+    // The projection (the last 8 bytes) can be set even if there isn't a camera.
+    if (std::all_of(data, data + 32, [](uint8_t byte){return byte == 0;})) return frame;
+    // Same steps as the installer takes with the camera of a field.
+    Ogre::Matrix3 matrix;
+    for (int column = 0; column < 3; ++ column)
+        for (int row = 0; row < 3; ++ row)
+            matrix[row][column] = static_cast<float>(ReadS16(data, (column * 3 + row) * 2)) / 4096.0f;
+    Ogre::Vector3 position;
+    for (int i = 0; i < 3; ++ i) position[i] = -static_cast<float>(ReadS32(data, 20 + i * 4));
+    Ogre::Quaternion orientation;
+    orientation.FromRotationMatrix(matrix);
+    position = orientation * (position / 128.0f);
+    for (int row = 0; row < 3; ++ row){
+        matrix[row][1] = -matrix[row][1];
+        matrix[row][2] = -matrix[row][2];
+    }
+    const float focal_length = static_cast<float>(static_cast<uint16_t>(ReadS16(data, 36)));
+    if (focal_length <= 0) return frame;
+    frame.position = position / (field_scale > 0 ? field_scale : 1.0f);
+    frame.orientation = Ogre::Quaternion(matrix);
+    frame.fov = Ogre::Radian(2 * Ogre::Math::ATan(SCREEN_HEIGHT / (2 * focal_length)));
+    frame.valid = true;
+    return frame;
+}

+ 65 - 0
src/core/MovieCameraFrame.h

@@ -0,0 +1,65 @@
+/*
+ * Copyright (C) 2022 The V-Gears Team
+ *
+ * This file is part of V-Gears
+ *
+ * V-Gears is free software: you can redistribute it and/or modify it under
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation, version 3.0 (GPLv3) of the License.
+ *
+ * V-Gears is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ */
+
+#pragma once
+
+#include <cstddef>
+#include <cstdint>
+#include <OgreQuaternion.h>
+#include <OgreMath.h>
+#include <OgreVector3.h>
+
+/**
+ * The camera of a frame of a movie, from the camera file the installer extracts from
+ * moviecam.lgp. It has the same layout as the camera of a field, and the same conversion applies.
+ */
+struct MovieCameraFrame{
+
+    /**
+     * Size in bytes of the data of a frame.
+     */
+    static const size_t SIZE = 40;
+
+    /**
+     * Whether the frame has a camera. The frames without one are all zero, and no model can be
+     * drawn over them.
+     */
+    bool valid = false;
+
+    /**
+     * Position of the camera, in the units of the field.
+     */
+    Ogre::Vector3 position = Ogre::Vector3::ZERO;
+
+    /**
+     * Orientation of the camera.
+     */
+    Ogre::Quaternion orientation = Ogre::Quaternion::IDENTITY;
+
+    /**
+     * Vertical field of view.
+     */
+    Ogre::Radian fov;
+
+    /**
+     * Reads the camera of a frame.
+     *
+     * @param[in] data The SIZE bytes of the frame.
+     * @param[in] field_scale Scale factor of the field. The camera is in the units of the
+     * original game and the field divides them by it.
+     * @return The camera. If there isn't one, it's not valid.
+     */
+    static MovieCameraFrame Parse(const uint8_t* data, const float field_scale);
+};

+ 316 - 0
src/core/MoviePlayer.cpp

@@ -0,0 +1,316 @@
+/*
+ * Copyright (C) 2022 The V-Gears Team
+ *
+ * This file is part of V-Gears
+ *
+ * V-Gears is free software: you can redistribute it and/or modify it under
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation, version 3.0 (GPLv3) of the License.
+ *
+ * V-Gears is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ */
+
+#include <cstdio>
+#include <fstream>
+#include <iterator>
+#include <vector>
+#include <tinyxml.h>
+#include <OgreHardwareBufferManager.h>
+#include <OgreHardwarePixelBuffer.h>
+#include <OgreImage.h>
+#include <OgreMaterialManager.h>
+#include <OgreRoot.h>
+#include <OgreTechnique.h>
+#include <OgreTextureManager.h>
+#include "core/AudioManager.h"
+#include "core/CameraManager.h"
+#include "core/EntityManager.h"
+#include "core/Logger.h"
+#include "core/MoviePlayer.h"
+
+namespace{
+
+    // Size of the frames of the original movies.
+    const int FRAME_WIDTH = 320;
+    const int FRAME_HEIGHT = 224;
+
+    // The texture is the next power of two of the frame.
+    const int TEXTURE_WIDTH = 512;
+    const int TEXTURE_HEIGHT = 256;
+
+    // The field is drawn as 320x240. The movie is centered, with bars above and below.
+    const float FIELD_HEIGHT = 240.0f;
+
+    const unsigned int QUAD_VERTEX_COUNT = 6;
+
+    // Position (3), colour (4) and texture coordinates (2).
+    const unsigned int QUAD_FLOATS_PER_VERTEX = 9;
+
+    const char* const RESOURCE_GROUP = "FIELDS";
+
+    std::string MovieDirectory(const std::string& name){return "./data/video/" + name + "/";}
+
+    std::string FramePath(const std::string& name, const int frame){
+        char number[16];
+        snprintf(number, sizeof(number), "%05d", frame);
+        return MovieDirectory(name) + number + ".jpg";
+    }
+}
+
+MoviePlayer::MoviePlayer(){
+    scene_manager_ = Ogre::Root::getSingleton().getSceneManager("Scene");
+    render_system_ = Ogre::Root::getSingletonPtr()->getRenderSystem();
+
+    texture_ = Ogre::TextureManager::getSingleton().createManual(
+      "MovieFrame", RESOURCE_GROUP, Ogre::TEX_TYPE_2D, TEXTURE_WIDTH, TEXTURE_HEIGHT, 0,
+      Ogre::PF_BYTE_RGBA, Ogre::TU_DYNAMIC_WRITE_ONLY_DISCARDABLE
+    );
+    material_ = Ogre::MaterialManager::getSingleton().create("MovieFrame", RESOURCE_GROUP);
+    Ogre::Pass* pass = material_->getTechnique(0)->getPass(0);
+    pass->setCullingMode(Ogre::CULL_NONE);
+    pass->setDepthCheckEnabled(false);
+    pass->setDepthWriteEnabled(false);
+    pass->setLightingEnabled(false);
+    Ogre::TextureUnitState* unit = pass->createTextureUnitState();
+    unit->setTexture(texture_);
+    unit->setTextureAddressingMode(Ogre::TextureUnitState::TAM_CLAMP);
+    unit->setTextureFiltering(Ogre::FO_LINEAR, Ogre::FO_LINEAR, Ogre::FO_NONE);
+
+    render_op_.vertexData = new Ogre::VertexData;
+    render_op_.vertexData->vertexStart = 0;
+    render_op_.vertexData->vertexCount = QUAD_VERTEX_COUNT;
+    Ogre::VertexDeclaration* declaration = render_op_.vertexData->vertexDeclaration;
+    size_t offset = 0;
+    declaration->addElement(0, 0, Ogre::VET_FLOAT3, Ogre::VES_POSITION);
+    offset += Ogre::VertexElement::getTypeSize(Ogre::VET_FLOAT3);
+    declaration->addElement(0, offset, Ogre::VET_FLOAT4, Ogre::VES_DIFFUSE);
+    offset += Ogre::VertexElement::getTypeSize(Ogre::VET_FLOAT4);
+    declaration->addElement(0, offset, Ogre::VET_FLOAT2, Ogre::VES_TEXTURE_COORDINATES);
+    vertex_buffer_ = Ogre::HardwareBufferManager::getSingletonPtr()->createVertexBuffer(
+      declaration->getVertexSize(0), QUAD_VERTEX_COUNT, Ogre::HardwareBuffer::HBU_DYNAMIC_WRITE_ONLY,
+      false
+    );
+    render_op_.vertexData->vertexBufferBinding->setBinding(0, vertex_buffer_);
+    render_op_.operationType = Ogre::RenderOperation::OT_TRIANGLE_LIST;
+    render_op_.useIndexes = false;
+    WriteQuad();
+
+    scene_manager_->addRenderQueueListener(this);
+}
+
+MoviePlayer::~MoviePlayer(){
+    scene_manager_->removeRenderQueueListener(this);
+    delete render_op_.vertexData;
+}
+
+bool MoviePlayer::ReadInfo(
+  int& frame_count, float& frames_per_second, int& camera_frame, int& release_frame
+) const{
+    TiXmlDocument document(MovieDirectory(name_) + "movie.xml");
+    if (!document.LoadFile()) return false;
+    const TiXmlElement* root = document.RootElement();
+    if (root == nullptr) return false;
+    double fps = 15;
+    camera_frame = -1;
+    root->QueryIntAttribute("frames", &frame_count);
+    root->QueryIntAttribute("camera_frame", &camera_frame);
+    // The frame at which the script that plays the movie goes on. By default, the end of the movie,
+    // which is what looks right in the opening.
+    release_frame = -1;
+    root->QueryIntAttribute("release_frame", &release_frame);
+    root->QueryDoubleAttribute("fps", &fps);
+    frames_per_second = static_cast<float>(fps);
+    return frame_count > 0;
+}
+
+void MoviePlayer::ScriptSet(const char* name, const float field_scale){
+    name_ = name;
+    field_scale_ = field_scale;
+    // A movie that is playing is not interrupted by choosing the next one.
+    if (!state_.IsPlaying()) state_.Prepare();
+    LOG_TRIVIAL("Movie '" + name_ + "' chosen.");
+}
+
+void MoviePlayer::ReleaseWaiting(){
+    for (const ScriptId& script : waiting_) ScriptManager::getSingleton().ContinueScriptExecution(script);
+    waiting_.clear();
+}
+
+int MoviePlayer::ScriptPlay(){
+    int frames = 0;
+    float fps = 15;
+    loaded_frame_ = -1;
+    int camera_frame = -1;
+    int release_frame = -1;
+    if (name_.empty() || !ReadInfo(frames, fps, camera_frame, release_frame)){
+        LOG_WARNING("Movie '" + name_ + "' is not installed, so it can't be played.");
+        state_.Start(0, fps);
+        return 1;
+    }
+    LOG_TRIVIAL("Movie '" + name_ + "' starts, " + std::to_string(frames) + " frames.");
+    state_.Start(frames, fps);
+    // The scenes of the fields that play over a movie are timed to start when the models can be
+    // drawn with the camera of the movie, so the script that plays it goes on from there.
+    release_frame_ = release_frame;
+    camera_data_.clear();
+    camera_valid_ = false;
+    std::ifstream camera_file(MovieDirectory(name_) + "camera.cam", std::ios::binary);
+    if (camera_file){
+        camera_data_.assign(
+          (std::istreambuf_iterator<char>(camera_file)), std::istreambuf_iterator<char>()
+        );
+    }
+    AudioManager::getSingleton().MoviePlay(MovieDirectory(name_) + name_ + ".ogg");
+    waiting_.push_back(ScriptManager::getSingleton().GetCurrentScriptId());
+    return -1;
+}
+
+bool MoviePlayer::IsDrawnBehind() const{
+    return state_.IsPlaying() && !camera_data_.empty();
+}
+
+void MoviePlayer::SetModelsHidden(const bool hidden){
+    if (hidden == models_hidden_) return;
+    models_hidden_ = hidden;
+    CameraManager::getSingleton().getViewport()->setVisibilityMask(hidden ? 0 : 0xFFFFFFFF);
+}
+
+void MoviePlayer::UpdateCamera(){
+    if (camera_data_.empty()) return;
+    if (!state_.IsPlaying()){
+        camera_valid_ = false;
+        SetModelsHidden(false);
+        if (camera_in_use_){
+            camera_in_use_ = false;
+            CameraManager::getSingleton().EndMovieCamera();
+        }
+        return;
+    }
+    const size_t offset = static_cast<size_t>(state_.GetFrame()) * MovieCameraFrame::SIZE;
+    camera_valid_ = false;
+    if (offset + MovieCameraFrame::SIZE <= camera_data_.size()){
+        const MovieCameraFrame frame = MovieCameraFrame::Parse(
+          camera_data_.data() + offset, field_scale_
+        );
+        camera_valid_ = frame.valid;
+        if (frame.valid){
+            camera_in_use_ = true;
+            CameraManager::getSingleton().SetMovieCamera(frame.position, frame.orientation, frame.fov);
+        }
+    }
+    SetModelsHidden(!camera_valid_);
+}
+
+void MoviePlayer::ScriptStop(){
+    state_.Stop();
+    UpdateCamera();
+    AudioManager::getSingleton().MovieStop();
+    ReleaseWaiting();
+}
+
+int MoviePlayer::ScriptGetFrame() const{return state_.GetFrame();}
+
+bool MoviePlayer::ScriptIsPlaying() const{return state_.IsPlaying();}
+
+void MoviePlayer::Update(const float delta){
+    const bool ended = state_.Advance(delta);
+    if (state_.IsPlaying() && release_frame_ >= 0 && state_.GetFrame() >= release_frame_){
+        LOG_TRIVIAL("Movie '" + name_ + "' reached frame " + std::to_string(state_.GetFrame()) + ".");
+        release_frame_ = -1;
+        ReleaseWaiting();
+    }
+    UpdateCamera();
+    if (ended){
+        LOG_TRIVIAL("Movie '" + name_ + "' ended.");
+        AudioManager::getSingleton().MovieStop();
+        ReleaseWaiting();
+    }
+}
+
+void MoviePlayer::LoadFrame(const int frame){
+    loaded_frame_ = frame;
+    std::ifstream file(FramePath(name_, frame), std::ios::binary);
+    if (!file) return;
+    std::vector<unsigned char> bytes(
+      (std::istreambuf_iterator<char>(file)), std::istreambuf_iterator<char>()
+    );
+    Ogre::DataStreamPtr stream(new Ogre::MemoryDataStream(bytes.data(), bytes.size(), false));
+    Ogre::Image image;
+    try{
+        image.load(stream, "jpg");
+    }
+    catch (const Ogre::Exception& e){
+        LOG_WARNING("Frame " + std::to_string(frame) + " of the movie can't be read: " + e.what());
+        return;
+    }
+    const Ogre::Box area(0, 0, image.getWidth(), image.getHeight());
+    texture_->getBuffer()->blitFromMemory(image.getPixelBox(), area);
+}
+
+void MoviePlayer::WriteQuad(){
+    const float half_height = FRAME_HEIGHT / FIELD_HEIGHT;
+    const float u = static_cast<float>(FRAME_WIDTH) / TEXTURE_WIDTH;
+    const float v = static_cast<float>(FRAME_HEIGHT) / TEXTURE_HEIGHT;
+    const float corners[QUAD_VERTEX_COUNT][4] = {
+      {-1, half_height, 0, 0}, {1, half_height, u, 0}, {1, -half_height, u, v},
+      {-1, half_height, 0, 0}, {1, -half_height, u, v}, {-1, -half_height, 0, v}
+    };
+    float* write_iterator
+      = static_cast<float*>(vertex_buffer_->lock(Ogre::HardwareBuffer::HBL_DISCARD));
+    for (unsigned int i = 0; i < QUAD_VERTEX_COUNT; ++ i){
+        *write_iterator ++ = corners[i][0];
+        *write_iterator ++ = corners[i][1];
+        *write_iterator ++ = 0;
+        *write_iterator ++ = 1;
+        *write_iterator ++ = 1;
+        *write_iterator ++ = 1;
+        *write_iterator ++ = 1;
+        *write_iterator ++ = corners[i][2];
+        *write_iterator ++ = corners[i][3];
+    }
+    vertex_buffer_->unlock();
+}
+
+void MoviePlayer::DrawQuad(){
+    const int frame = state_.GetFrame();
+    if (frame != loaded_frame_) LoadFrame(frame);
+
+    // Same setup the background and the fades use: the quad is given in screen coordinates.
+    Ogre::GpuProgramParametersPtr rs_params = render_system_->getFixedFunctionParams(
+      Ogre::TVC_NONE, Ogre::FOG_NONE
+    );
+    rs_params->setConstant(Ogre::GpuProgramParameters::ACT_WORLD_MATRIX, Ogre::Matrix4::IDENTITY);
+    rs_params->setConstant(
+      Ogre::GpuProgramParameters::ACT_PROJECTION_MATRIX, Ogre::Matrix4::IDENTITY
+    );
+    render_system_->_setViewMatrix(Ogre::Matrix4::IDENTITY);
+    rs_params->setConstant(Ogre::GpuProgramParameters::ACT_VIEW_MATRIX, Ogre::Matrix4::IDENTITY);
+    render_system_->applyFixedFunctionParams(rs_params, Ogre::GPV_GLOBAL);
+    scene_manager_->_setPass(material_->getTechnique(0)->getPass(0), true, false);
+    render_system_->_render(render_op_);
+}
+
+void MoviePlayer::renderQueueStarted(
+  Ogre::uint8 queue_group_id, const Ogre::String& invocation, bool& skip_this_invocation
+){
+    if (!state_.IsPlaying()) return;
+    if (!EntityManager::getSingleton().IsFieldModule()) return;
+    if (!camera_data_.empty()){
+        // Behind the models, which come in the main queue. They are only drawn if the movie has a
+        // camera in the frame, or they would be in the wrong place. They are hidden with the
+        // visibility mask of the viewport: skipping the queue would also skip the listeners that
+        // are called when it ends, and the debug lines would pile up.
+        if (queue_group_id != Ogre::RENDER_QUEUE_MAIN) return;
+        DrawQuad();
+        // The queue sets its own matrices, but not before it starts.
+        Ogre::Camera* camera = CameraManager::getSingleton().GetCurrentCamera();
+        render_system_->_setProjectionMatrix(camera->getProjectionMatrixRS());
+        render_system_->_setViewMatrix(camera->getViewMatrix(true));
+        return;
+    }
+    if (queue_group_id != Ogre::RENDER_QUEUE_OVERLAY) return;
+    DrawQuad();
+}

+ 240 - 0
src/core/MoviePlayer.h

@@ -0,0 +1,240 @@
+/*
+ * Copyright (C) 2022 The V-Gears Team
+ *
+ * This file is part of V-Gears
+ *
+ * V-Gears is free software: you can redistribute it and/or modify it under
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation, version 3.0 (GPLv3) of the License.
+ *
+ * V-Gears is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ */
+
+#pragma once
+
+#include <cstdint>
+#include <string>
+#include <vector>
+#include <OgreHardwareVertexBuffer.h>
+#include <OgreMaterial.h>
+#include <OgreRenderOperation.h>
+#include <OgreRenderQueueListener.h>
+#include <OgreRenderSystem.h>
+#include <OgreSceneManager.h>
+#include <OgreTexture.h>
+#include "MovieCameraFrame.h"
+#include "MovieState.h"
+#include "ScriptManager.h"
+
+/**
+ * Plays the movies that the installer converted: a folder of JPEG frames and an ogg file with the
+ * sound, in data/video/NAME/.
+ *
+ * The movie is drawn over the field and below the interface and the screen fades.
+ */
+class MoviePlayer : public Ogre::RenderQueueListener{
+
+    public:
+
+        /**
+         * Constructor.
+         */
+        MoviePlayer();
+
+        /**
+         * Destructor.
+         */
+        virtual ~MoviePlayer();
+
+        /**
+         * Run each frame. Moves the movie forward.
+         *
+         * @param[in] delta Seconds since the last frame.
+         */
+        void Update(const float delta);
+
+        /**
+         * Chooses the movie to play next, like the PMVIE opcode.
+         *
+         * @param[in] name Name of the movie, without extension.
+         * @param[in] field_scale Scale factor of the field, to place the models over the movie.
+         */
+        void ScriptSet(const char* name, const float field_scale);
+
+        /**
+         * Starts the chosen movie, like the MOVIE opcode. The script that starts it waits until
+         * the movie ends. The other scripts go on while it's played.
+         *
+         * @return 1 if the script doesn't have to wait, and -1 if it does.
+         */
+        int ScriptPlay();
+
+        /**
+         * Stops the movie.
+         */
+        void ScriptStop();
+
+        /**
+         * Retrieves the frame being shown, like the MVIEF opcode.
+         *
+         * @return The frame. If the chosen movie isn't installed, a number high enough to be
+         * after any frame the scripts wait for.
+         */
+        int ScriptGetFrame() const;
+
+        /**
+         * @return True while a movie is being played.
+         */
+        bool ScriptIsPlaying() const;
+
+        /**
+         * Checks if the movie is drawn behind the field, which is drawn over it with the camera of
+         * the movie. If not, the movie covers the field.
+         *
+         * @return True if a movie with a camera is being played.
+         */
+        bool IsDrawnBehind() const;
+
+        /**
+         * Draws the movie. If it has a camera it's drawn behind the models, and the models are
+         * only drawn in the frames that have a camera. If not, it's drawn over the field and below
+         * the interface.
+         */
+        void renderQueueStarted(
+          Ogre::uint8 queue_group_id, const Ogre::String& invocation, bool& skip_this_invocation
+        ) override;
+
+    private:
+
+        /**
+         * Reads the information of the chosen movie.
+         *
+         * @param[out] frame_count Number of frames.
+         * @param[out] frames_per_second Frames per second.
+         * @param[out] camera_frame First frame with a camera for the models, or -1.
+         * @param[out] release_frame Frame at which the script that plays the movie goes on, or -1
+         * for the end.
+         * @return True if the movie is installed.
+         */
+        bool ReadInfo(
+          int& frame_count, float& frames_per_second, int& camera_frame, int& release_frame
+        ) const;
+
+        /**
+         * Content of the camera file of the movie. Empty if it hasn't got one.
+         */
+        std::vector<uint8_t> camera_data_;
+
+        /**
+         * Scale factor of the field.
+         */
+        float field_scale_ = 1.0f;
+
+        /**
+         * Whether the current frame of the movie has a camera.
+         */
+        bool camera_valid_ = false;
+
+        /**
+         * Whether the movie camera is in use.
+         */
+        bool camera_in_use_ = false;
+
+        /**
+         * Frame at which the script that played the movie goes on, or -1 for the end of the movie.
+         */
+        int release_frame_ = -1;
+
+        /**
+         * Places the camera of the field in the one of the current frame of the movie, or puts it
+         * back if the movie has ended.
+         */
+        void UpdateCamera();
+
+        /**
+         * Hides or shows everything the viewport draws as an object of the scene, the models.
+         *
+         * @param[in] hidden True to hide them.
+         */
+        void SetModelsHidden(const bool hidden);
+
+        /**
+         * Whether the models are hidden because the frame has no camera.
+         */
+        bool models_hidden_ = false;
+
+        /**
+         * Draws the quad with the current frame.
+         */
+        void DrawQuad();
+
+        /**
+         * Loads a frame in the texture.
+         *
+         * @param[in] frame The frame to load.
+         */
+        void LoadFrame(const int frame);
+
+        /**
+         * Writes the corners of the screen quad.
+         */
+        void WriteQuad();
+
+        /**
+         * The scripts that wait for the end of the movie.
+         */
+        std::vector<ScriptId> waiting_;
+
+        /**
+         * Resumes the scripts that wait for the end of the movie.
+         */
+        void ReleaseWaiting();
+
+        /**
+         * The clock of the movie.
+         */
+        MovieState state_;
+
+        /**
+         * Name of the chosen movie.
+         */
+        std::string name_;
+
+        /**
+         * Frame in the texture, or -1.
+         */
+        int loaded_frame_ = -1;
+
+        /**
+         * Scene manager.
+         */
+        Ogre::SceneManager* scene_manager_;
+
+        /**
+         * Render system.
+         */
+        Ogre::RenderSystem* render_system_;
+
+        /**
+         * Texture the frames are copied to.
+         */
+        Ogre::TexturePtr texture_;
+
+        /**
+         * Material of the quad.
+         */
+        Ogre::MaterialPtr material_;
+
+        /**
+         * Render operation of the quad.
+         */
+        Ogre::RenderOperation render_op_;
+
+        /**
+         * Vertex buffer of the quad.
+         */
+        Ogre::HardwareVertexBufferSharedPtr vertex_buffer_;
+};

+ 55 - 0
src/core/MovieState.cpp

@@ -0,0 +1,55 @@
+/*
+ * Copyright (C) 2022 The V-Gears Team
+ *
+ * This file is part of V-Gears
+ *
+ * V-Gears is free software: you can redistribute it and/or modify it under
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation, version 3.0 (GPLv3) of the License.
+ *
+ * V-Gears is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ */
+
+#include <algorithm>
+#include <cmath>
+#include "core/MovieState.h"
+
+// Defined here too, as the tests and the scripts take it by reference.
+const int MovieState::NO_MOVIE_FRAME;
+
+void MovieState::Start(const int frame_count, const float frames_per_second){
+    frame_count_ = std::max(frame_count, 0);
+    // A movie without frames is one that isn't there.
+    started_ = frame_count_ > 0;
+    frames_per_second_ = frames_per_second > 0 ? frames_per_second : 15.0f;
+    elapsed_ = 0;
+    playing_ = frame_count_ > 0;
+}
+
+void MovieState::Prepare(){
+    started_ = true;
+    playing_ = false;
+    frame_count_ = 0;
+    elapsed_ = 0;
+}
+
+void MovieState::Stop(){playing_ = false;}
+
+bool MovieState::Advance(const float seconds){
+    if (!playing_) return false;
+    elapsed_ += seconds;
+    if (GetFrame() < frame_count_) return false;
+    playing_ = false;
+    return true;
+}
+
+bool MovieState::IsPlaying() const{return playing_;}
+
+int MovieState::GetFrame() const{
+    if (!started_) return NO_MOVIE_FRAME;
+    const int frame = static_cast<int>(std::floor(elapsed_ * frames_per_second_));
+    return std::min(frame, frame_count_);
+}

+ 100 - 0
src/core/MovieState.h

@@ -0,0 +1,100 @@
+/*
+ * Copyright (C) 2022 The V-Gears Team
+ *
+ * This file is part of V-Gears
+ *
+ * V-Gears is free software: you can redistribute it and/or modify it under
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation, version 3.0 (GPLv3) of the License.
+ *
+ * V-Gears is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ */
+
+#pragma once
+
+/**
+ * The clock of a movie: which frame is shown at each moment, and when it ends.
+ *
+ * It has no knowledge of the video or the screen. It's separated from {@see MoviePlayer} so its
+ * rules can be tested.
+ */
+class MovieState{
+
+    public:
+
+        /**
+         * Frame reported when there is no movie to play. It's high so the scripts that wait for a
+         * frame of the movie to continue don't wait for ever.
+         */
+        static const int NO_MOVIE_FRAME = 32767;
+
+        /**
+         * Starts the movie from its first frame.
+         *
+         * @param[in] frame_count Number of frames of the movie.
+         * @param[in] frames_per_second Frames shown per second.
+         */
+        void Start(const int frame_count, const float frames_per_second);
+
+        /**
+         * Chooses a movie without playing it. Until it starts the frame is 0, which is what the
+         * scripts that wait for a frame of the movie expect before it starts.
+         */
+        void Prepare();
+
+        /**
+         * Stops the movie. The frame it reaches is kept.
+         */
+        void Stop();
+
+        /**
+         * Moves the clock forward.
+         *
+         * @param[in] seconds Seconds since the last update.
+         * @return True if the movie ended in this update.
+         */
+        bool Advance(const float seconds);
+
+        /**
+         * @return True while the movie is being played.
+         */
+        bool IsPlaying() const;
+
+        /**
+         * Retrieves the frame that must be shown.
+         *
+         * @return The frame, starting at 0. When the movie ended, the number of frames. If there
+         * was no movie, {@see NO_MOVIE_FRAME}.
+         */
+        int GetFrame() const;
+
+    private:
+
+        /**
+         * Whether the movie is being played.
+         */
+        bool playing_ = false;
+
+        /**
+         * Whether a movie has been started.
+         */
+        bool started_ = false;
+
+        /**
+         * Number of frames of the movie.
+         */
+        int frame_count_ = 0;
+
+        /**
+         * Frames per second.
+         */
+        float frames_per_second_ = 15.0f;
+
+        /**
+         * Seconds played.
+         */
+        float elapsed_ = 0;
+};

+ 11 - 0
src/core/ScriptManagerBinds.h

@@ -451,6 +451,16 @@ void ScriptManager::InitBinds(){
       "fade", &ScreenFade::ScriptFade,
       "fade", &ScreenFade::ScriptFade,
       "nfade", &ScreenFade::ScriptNFade
       "nfade", &ScreenFade::ScriptNFade
     );
     );
+    // Movies
+    lua.new_usertype<MoviePlayer>(
+      "MoviePlayer",
+      "set", &MoviePlayer::ScriptSet,
+      "stop", &MoviePlayer::ScriptStop,
+      "get_frame", &MoviePlayer::ScriptGetFrame,
+      "is_playing", &MoviePlayer::ScriptIsPlaying
+    );
+    sol::table movie_player_type = lua["MoviePlayer"];
+    BindYielding(movie_player_type, "play", &MoviePlayer::ScriptPlay);
     sol::table screen_fade_type = lua["ScreenFade"];
     sol::table screen_fade_type = lua["ScreenFade"];
     BindYielding(screen_fade_type, "wait", &ScreenFade::ScriptWait);
     BindYielding(screen_fade_type, "wait", &ScreenFade::ScriptWait);
 
 
@@ -581,6 +591,7 @@ void ScriptManager::InitBinds(){
     lua["savemap_manager"] = SavemapHandler::getSingletonPtr();
     lua["savemap_manager"] = SavemapHandler::getSingletonPtr();
     lua["background2d"] = EntityManager::getSingletonPtr()->GetBackground2D();
     lua["background2d"] = EntityManager::getSingletonPtr()->GetBackground2D();
     lua["screen_fade"] = EntityManager::getSingletonPtr()->GetScreenFade();
     lua["screen_fade"] = EntityManager::getSingletonPtr()->GetScreenFade();
+    lua["movie_player"] = EntityManager::getSingletonPtr()->GetMoviePlayer();
     lua["walkmesh"] = EntityManager::getSingletonPtr()->GetWalkmesh();
     lua["walkmesh"] = EntityManager::getSingletonPtr()->GetWalkmesh();
     lua["text_manager"] = TextHandler::getSingletonPtr();
     lua["text_manager"] = TextHandler::getSingletonPtr();
     lua["dialog"] = DialogsManager::getSingletonPtr();
     lua["dialog"] = DialogsManager::getSingletonPtr();

+ 1 - 0
src/installer/CMakeLists.txt

@@ -18,6 +18,7 @@ set(INSTALLER_SOURCE_FILES
     ExternalTools.cpp
     ExternalTools.cpp
     InstallVerifier.cpp
     InstallVerifier.cpp
     VideoFiles.cpp
     VideoFiles.cpp
+    MovieCamera.cpp
     KernelDataInstaller.cpp
     KernelDataInstaller.cpp
     MainWindow.cpp
     MainWindow.cpp
     MediaDataInstaller.cpp
     MediaDataInstaller.cpp

+ 52 - 0
src/installer/ExternalTools.cpp

@@ -14,12 +14,15 @@
  */
  */
 
 
 #include "ExternalTools.h"
 #include "ExternalTools.h"
+#include "MovieCamera.h"
 #include <QtCore/QProcess>
 #include <QtCore/QProcess>
 #include <QtCore/QString>
 #include <QtCore/QString>
 #include <QtCore/QStringList>
 #include <QtCore/QStringList>
 #include <algorithm>
 #include <algorithm>
 #include <boost/filesystem.hpp>
 #include <boost/filesystem.hpp>
 #include <cctype>
 #include <cctype>
+#include <cstdio>
+#include <fstream>
 #include <cstdlib>
 #include <cstdlib>
 
 
 namespace fs = boost::filesystem;
 namespace fs = boost::filesystem;
@@ -259,6 +262,14 @@ namespace ExternalTools{
         return {"--quiet=3", midi, "-Ow", "-o", "-"};
         return {"--quiet=3", midi, "-Ow", "-o", "-"};
     }
     }
 
 
+    namespace{
+        std::string FrameName(const int frame){
+            char number[16];
+            snprintf(number, sizeof(number), "%05d", frame);
+            return std::string(number) + ".jpg";
+        }
+    }
+
     bool ConvertToOgg(
     bool ConvertToOgg(
       const std::string& ffmpeg, const std::string& input, const std::string& output, std::string* error
       const std::string& ffmpeg, const std::string& input, const std::string& output, std::string* error
     ){
     ){
@@ -271,6 +282,47 @@ namespace ExternalTools{
         return false;
         return false;
     }
     }
 
 
+    bool ConvertMovie(
+      const std::string& ffmpeg, const std::string& input, const std::string& directory,
+      const std::string& name, const std::vector<uint8_t>& camera, std::string* error
+    ){
+        boost::system::error_code ec;
+        fs::remove_all(directory, ec);
+        fs::create_directories(directory, ec);
+        // The movies of the original game have 15 frames per second.
+        const float frames_per_second = 15.0f;
+        const RunResult frames = Run(
+          ffmpeg,
+          {"-hide_banner", "-loglevel", "panic", "-y", "-i", input, "-an", "-q:v", "3",
+           "-start_number", "0", directory + "/%05d.jpg"},
+          -1
+        );
+        int frame_count = 0;
+        while (fs::exists(directory + "/" + FrameName(frame_count), ec)) ++ frame_count;
+        if (!frames.started || !frames.finished || frames.exit_code != 0 || frame_count == 0){
+            fs::remove_all(directory, ec);
+            if (error) *error = "ffmpeg failed: " + Describe(frames);
+            return false;
+        }
+        // The sound. Some movies have none.
+        const std::string ogg = directory + "/" + name + ".ogg";
+        const RunResult sound = Run(
+          ffmpeg, {"-hide_banner", "-loglevel", "panic", "-y", "-i", input, "-vn", ogg}, -1
+        );
+        if (!sound.started || !sound.finished || sound.exit_code != 0 || fs::file_size(ogg, ec) == 0 || ec)
+            fs::remove(ogg, ec);
+        std::ofstream info(directory + "/movie.xml");
+        info << "<movie frames=\"" << frame_count << "\" fps=\"" << frames_per_second << "\"";
+        const int first_camera_frame = MovieCamera::FirstCameraFrame(camera);
+        if (first_camera_frame >= 0){
+            info << " camera_frame=\"" << first_camera_frame << "\"";
+            std::ofstream camera_file(directory + "/camera.cam", std::ios::binary);
+            camera_file.write(reinterpret_cast<const char*>(camera.data()), camera.size());
+        }
+        info << "/>\n";
+        return true;
+    }
+
     bool ConvertMidiToOgg(
     bool ConvertMidiToOgg(
       const std::string& timidity, const std::string& ffmpeg, const std::string& midi, const std::string& output,
       const std::string& timidity, const std::string& ffmpeg, const std::string& midi, const std::string& output,
       std::string* error
       std::string* error

+ 21 - 0
src/installer/ExternalTools.h

@@ -15,6 +15,7 @@
 
 
 #pragma once
 #pragma once
 
 
+#include <cstdint>
 #include <string>
 #include <string>
 #include <vector>
 #include <vector>
 
 
@@ -202,6 +203,26 @@ namespace ExternalTools{
       const std::string& ffmpeg, const std::string& input, const std::string& output, std::string* error = nullptr
       const std::string& ffmpeg, const std::string& input, const std::string& output, std::string* error = nullptr
     );
     );
 
 
+    /**
+     * Converts a movie to what the game plays: a folder with its frames as JPEG files (00000.jpg,
+     * 00001.jpg...), an ogg file with its sound, and movie.xml with the number of frames and the
+     * frames per second.
+     *
+     * @param[in] ffmpeg Path of FFmpeg.
+     * @param[in] input The movie.
+     * @param[in] directory Folder to create. It is removed if the conversion fails.
+     * @param[in] name Name of the movie, which is the name of the ogg file.
+     * @param[in] camera Content of the camera file of the movie, which the game uses to draw the
+     * models over it. It's written as camera.cam, and the first frame that has a camera in
+     * movie.xml. Empty if the movie has no camera.
+     * @param[out] error Reason of the failure. Optional.
+     * @return True if the frames were created. A movie without sound is converted anyway.
+     */
+    bool ConvertMovie(
+      const std::string& ffmpeg, const std::string& input, const std::string& directory,
+      const std::string& name, const std::vector<uint8_t>& camera = {}, std::string* error = nullptr
+    );
+
     /**
     /**
      * Converts a MIDI file to ogg: TiMidity renders it and its output is sent to FFmpeg.
      * Converts a MIDI file to ogg: TiMidity renders it and its output is sent to FFmpeg.
      *
      *

+ 1 - 1
src/installer/InstallOptions.cpp

@@ -78,7 +78,7 @@ namespace InstallRules{
 
 
     ToolNeeds ToolsNeeded(const InstallOptions& o){
     ToolNeeds ToolsNeeded(const InstallOptions& o){
         ToolNeeds needs;
         ToolNeeds needs;
-        needs.ffmpeg = o.sounds || o.music;
+        needs.ffmpeg = o.sounds || o.music || o.videos;
         needs.timidity = o.music;
         needs.timidity = o.music;
         return needs;
         return needs;
     }
     }

+ 37 - 0
src/installer/MainWindow.cpp

@@ -44,8 +44,44 @@
 #include "InstallOptions.h"
 #include "InstallOptions.h"
 #include "InstallVerifier.h"
 #include "InstallVerifier.h"
 #include "MainWindow.h"
 #include "MainWindow.h"
+#include "MovieCamera.h"
 #include <ui_MainWindow.h>
 #include <ui_MainWindow.h>
 
 
+namespace{
+
+    /**
+     * Converts the movies in a folder to what the game plays: frames and sound. The game can't
+     * decode the original videos. The movies that already have a converted folder are skipped,
+     * and the originals are kept so they are not extracted again.
+     *
+     * @param[in] video_dir Folder with the extracted movies.
+     */
+    void ConvertMovies(const std::string& video_dir){
+        const ExternalTools::Tool ffmpeg = ExternalTools::FindFfmpeg(ExternalTools::CurrentLocations());
+        if (!ffmpeg.usable){
+            InstallLog::Get().Add(
+              "[WARNING] FFmpeg can't be used, so the videos are not converted and the game can't play them: "
+              + ffmpeg.problem
+            );
+            return;
+        }
+        QDir directory(QString::fromStdString(video_dir));
+        for (const QString& file : directory.entryList({"*.avi"}, QDir::Files)){
+            const std::string name = QFileInfo(file).completeBaseName().toStdString();
+            const std::string destination = video_dir + name;
+            if (QFileInfo::exists(QString::fromStdString(destination + "/movie.xml"))) continue;
+            InstallLog::Get().Add("Converting " + name + ".avi...");
+            std::string error;
+            if (ExternalTools::ConvertMovie(
+                  ffmpeg.path, video_dir + file.toStdString(), destination, name,
+                  MovieCamera::LoadCamera(video_dir + "moviecam.lgp", name), &error
+                ))
+                InstallLog::Get().Add("Converted " + name);
+            else InstallLog::Get().Add("[WARNING] Could not convert " + name + ": " + error);
+        }
+    }
+}
+
 /**
 /**
  * Indicates if an installer has already been created.
  * Indicates if an installer has already been created.
  */
  */
@@ -880,6 +916,7 @@ void MainWindow::StartVideoExtraction(const QString& output){
               },
               },
               [](const std::string& message){InstallLog::Get().Add(message);}, &extracted, &skipped
               [](const std::string& message){InstallLog::Get().Add(message);}, &extracted, &skipped
             );
             );
+            if (ok) ConvertMovies(output.toStdString() + "data/video/");
         }
         }
         catch (const std::exception& ex){
         catch (const std::exception& ex){
             error = ex.what();
             error = ex.what();

+ 23 - 1
src/installer/MediaDataInstaller.cpp

@@ -28,6 +28,7 @@
 #include "MediaDataInstaller.h"
 #include "MediaDataInstaller.h"
 #include "ExternalTools.h"
 #include "ExternalTools.h"
 #include "InstallLog.h"
 #include "InstallLog.h"
+#include "MovieCamera.h"
 #include "SfxDump.h"
 #include "SfxDump.h"
 #include "data/VGearsLGPArchive.h"
 #include "data/VGearsLGPArchive.h"
 #include "data/VGearsTexFile.h"
 #include "data/VGearsTexFile.h"
@@ -1613,7 +1614,28 @@ void MediaDataInstaller::InstallVideos(){
     const std::string output_dir = data_dir_ + "video/";
     const std::string output_dir = data_dir_ + "video/";
     boost::filesystem::create_directories(output_dir);
     boost::filesystem::create_directories(output_dir);
     const boost::filesystem::path source(videos_[processed_videos_]);
     const boost::filesystem::path source(videos_[processed_videos_]);
+    processed_videos_ ++;
+    if (ToolsReady(false)){
+        // The game can't decode these videos, so they are converted to frames and ogg sound, in the
+        // background. Videos that aren't avi files are not movies.
+        if (source.extension() != ".avi" && source.extension() != ".AVI") return;
+        const std::string ffmpeg = ffmpeg_tool_.path;
+        const std::string name = source.stem().string();
+        const std::string input = source.string();
+        const std::string camera_archive = (source.parent_path() / "moviecam.lgp").string();
+        const std::string directory = (boost::filesystem::path(output_dir) / name).string();
+        RunConversion(
+          "movie " + name,
+          [ffmpeg, input, directory, name, camera_archive](std::string* error){
+              return ExternalTools::ConvertMovie(
+                ffmpeg, input, directory, name, MovieCamera::LoadCamera(camera_archive, name),
+                error
+              );
+          },
+          nullptr
+        );
+        return;
+    }
     const boost::filesystem::path destination = boost::filesystem::path(output_dir) / source.filename();
     const boost::filesystem::path destination = boost::filesystem::path(output_dir) / source.filename();
     boost::filesystem::copy_file(source, destination, boost::filesystem::copy_options::overwrite_existing);
     boost::filesystem::copy_file(source, destination, boost::filesystem::copy_options::overwrite_existing);
-    processed_videos_ ++;
 }
 }

+ 86 - 0
src/installer/MovieCamera.cpp

@@ -0,0 +1,86 @@
+/*
+ * Copyright (C) 2022 The V-Gears Team
+ *
+ * This file is part of V-Gears
+ *
+ * V-Gears is free software: you can redistribute it and/or modify it under
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation, version 3.0 (GPLv3) of the License.
+ *
+ * V-Gears is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ */
+
+#include <algorithm>
+#include <cstring>
+#include <fstream>
+#include <iterator>
+#include "MovieCamera.h"
+
+namespace{
+
+    uint32_t ReadU32(const std::vector<uint8_t>& data, const size_t offset){
+        uint32_t value = 0;
+        std::memcpy(&value, data.data() + offset, sizeof(value));
+        return value;
+    }
+
+    // Header of the archive: 12 bytes of padding and "SQUARESOFT", then the number of files.
+    const size_t COUNT_OFFSET = 12;
+    const size_t TABLE_OFFSET = 16;
+
+    // Each entry of the table: name (20), offset (4), type (1) and duplicate counter (2).
+    const size_t ENTRY_SIZE = 27;
+    const size_t NAME_SIZE = 20;
+
+    // Each file starts with its name (20) and its size (4).
+    const size_t FILE_HEADER_SIZE = 24;
+}
+
+namespace MovieCamera{
+
+    int FirstCameraFrame(const std::vector<uint8_t>& camera){
+        const size_t frames = camera.size() / FRAME_SIZE;
+        for (size_t frame = 0; frame < frames; ++ frame){
+            const auto begin = camera.begin() + frame * FRAME_SIZE;
+            // The last 8 bytes are the projection, which can be set even if there is no camera.
+            if (std::any_of(begin, begin + 32, [](uint8_t byte){return byte != 0;}))
+                return static_cast<int>(frame);
+        }
+        return -1;
+    }
+
+    bool ReadLgpFile(
+      const std::vector<uint8_t>& archive, const std::string& name, std::vector<uint8_t>& content
+    ){
+        if (archive.size() < TABLE_OFFSET) return false;
+        const uint32_t count = ReadU32(archive, COUNT_OFFSET);
+        if (archive.size() < TABLE_OFFSET + static_cast<size_t>(count) * ENTRY_SIZE) return false;
+        for (uint32_t i = 0; i < count; ++ i){
+            const size_t entry = TABLE_OFFSET + static_cast<size_t>(i) * ENTRY_SIZE;
+            const char* entry_name = reinterpret_cast<const char*>(archive.data() + entry);
+            if (std::string(entry_name, strnlen(entry_name, NAME_SIZE)) != name) continue;
+            const size_t offset = ReadU32(archive, entry + NAME_SIZE);
+            if (archive.size() < offset + FILE_HEADER_SIZE) return false;
+            const size_t size = ReadU32(archive, offset + NAME_SIZE);
+            if (archive.size() < offset + FILE_HEADER_SIZE + size) return false;
+            const auto begin = archive.begin() + offset + FILE_HEADER_SIZE;
+            content.assign(begin, begin + size);
+            return true;
+        }
+        return false;
+    }
+
+    std::vector<uint8_t> LoadCamera(const std::string& lgp_path, const std::string& movie){
+        std::vector<uint8_t> camera;
+        std::ifstream file(lgp_path, std::ios::binary);
+        if (!file) return camera;
+        const std::vector<uint8_t> archive(
+          (std::istreambuf_iterator<char>(file)), std::istreambuf_iterator<char>()
+        );
+        if (!ReadLgpFile(archive, movie + ".cam", camera)) camera.clear();
+        return camera;
+    }
+}

+ 64 - 0
src/installer/MovieCamera.h

@@ -0,0 +1,64 @@
+/*
+ * Copyright (C) 2022 The V-Gears Team
+ *
+ * This file is part of V-Gears
+ *
+ * V-Gears is free software: you can redistribute it and/or modify it under
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation, version 3.0 (GPLv3) of the License.
+ *
+ * V-Gears is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ */
+
+#pragma once
+
+#include <cstdint>
+#include <string>
+#include <vector>
+
+/**
+ * The cameras of the movies, in moviecam.lgp.
+ *
+ * Each movie has a camera file, with 40 bytes per frame: a rotation matrix, a translation and the
+ * projection. The models of a field are drawn with it over the movie. The frames in which the
+ * movie has no camera are all zero.
+ */
+namespace MovieCamera{
+
+    /**
+     * Size of the camera data of a frame.
+     */
+    const size_t FRAME_SIZE = 40;
+
+    /**
+     * Retrieves the first frame of a movie that has a camera.
+     *
+     * @param[in] camera Content of the camera file.
+     * @return The frame, or -1 if no frame has a camera.
+     */
+    int FirstCameraFrame(const std::vector<uint8_t>& camera);
+
+    /**
+     * Reads a file of an LGP archive.
+     *
+     * @param[in] archive Content of the archive.
+     * @param[in] name Name of the file, such as "opening.cam".
+     * @param[out] content The content of the file.
+     * @return True if the file was found.
+     */
+    bool ReadLgpFile(
+      const std::vector<uint8_t>& archive, const std::string& name, std::vector<uint8_t>& content
+    );
+
+    /**
+     * Reads the camera of a movie from moviecam.lgp.
+     *
+     * @param[in] lgp_path Path to moviecam.lgp.
+     * @param[in] movie Name of the movie, without extension.
+     * @return The content of the camera file. Empty if the archive or the camera can't be read.
+     */
+    std::vector<uint8_t> LoadCamera(const std::string& lgp_path, const std::string& movie);
+}

+ 97 - 5
src/installer/decompiler/field/instruction/FieldMediaInstruction.cpp

@@ -209,22 +209,114 @@ void FieldMediaInstruction::ProcessMULCK(CodeGenerator* code_gen){
     ).str());
     ).str());
 }
 }
 
 
+namespace{
+
+    // The movies, in the order the game identifies them. It's the list in FF7.EXE.
+    const char* const MOVIES[] = {
+      "fship2",
+      "fship2n",
+      "d_ropego",
+      "d_ropein",
+      "u_ropein",
+      "u_ropego",
+      "gold2",
+      "gold3",
+      "gold4",
+      "gold6",
+      "gold5",
+      "boogup",
+      "boogdown",
+      "junair_u",
+      "junair_d",
+      "junelein",
+      "junelego",
+      "junin_in",
+      "junin_go",
+      "moriya",
+      "mkup",
+      "northmk",
+      "mk8",
+      "ontrain",
+      "mainplr",
+      "smk",
+      "southmk",
+      "plrexp",
+      "fallpl",
+      "monitor",
+      "bike",
+      "mtnvl",
+      "mtnvl2",
+      "brgnvl",
+      "nvlmk",
+      "nivlsfs",
+      "jenova_e",
+      "junon",
+      "hiwind0",
+      "mtcrl",
+      "gold1",
+      "biskdead",
+      "boogdemo",
+      "boogstar",
+      "setogake",
+      "rcktfail",
+      "jairofly",
+      "jairofal",
+      "gold7",
+      "gold7_2",
+      "earithdd",
+      "funeral",
+      "car_1209",
+      "opening",
+      "greatpit",
+      "c_scene1",
+      "c_scene2",
+      "c_scene3",
+      "biglight",
+      "meteosky",
+      "weapon0",
+      "weapon1",
+      "weapon2",
+      "weapon3",
+      "weapon4",
+      "weapon5",
+      "hwindfly",
+      "phoenix",
+      "nrcrl",
+      "nrcrl_b",
+      "dumcrush",
+      "zmind01",
+      "zmind02",
+      "zmind03",
+      "gelnica",
+      "rcketoff",
+      "white2"
+    };
+}
+
 void FieldMediaInstruction::ProcessPMVIE(CodeGenerator* code_gen){
 void FieldMediaInstruction::ProcessPMVIE(CodeGenerator* code_gen){
-    code_gen->AddOutputLine(
-      (boost::format("-- field:movie_set(%1%)") % params_[0]->GetUnsigned()
+    const size_t id = params_[0]->GetUnsigned();
+    if (id >= sizeof(MOVIES) / sizeof(MOVIES[0])){
+        code_gen->AddOutputLine(
+          (boost::format("-- Unknown movie %1%") % id).str()
+        );
+        return;
+    }
+    // The scale of the field, to place the models over the movie.
+    FieldCodeGenerator* cg = static_cast<FieldCodeGenerator*>(code_gen);
+    code_gen->AddOutputLine((
+      boost::format("movie_player:set(\"%1%\", %2%)") % MOVIES[id] % cg->GetScaleFactor()
     ).str());
     ).str());
 }
 }
 
 
 void FieldMediaInstruction::ProcessMOVIE(CodeGenerator* code_gen){
 void FieldMediaInstruction::ProcessMOVIE(CodeGenerator* code_gen){
-    code_gen->AddOutputLine("-- field:play_movie()");
+    code_gen->AddOutputLine("movie_player:play()");
 }
 }
 
 
 void FieldMediaInstruction::ProcessMVIEF(CodeGenerator* code_gen){
 void FieldMediaInstruction::ProcessMVIEF(CodeGenerator* code_gen){
-    // TODO: Check for assignment to value.
     FieldCodeGenerator* cg = static_cast<FieldCodeGenerator*>(code_gen);
     FieldCodeGenerator* cg = static_cast<FieldCodeGenerator*>(code_gen);
     const auto& destination = FieldCodeGenerator::FormatValueOrVariable(
     const auto& destination = FieldCodeGenerator::FormatValueOrVariable(
       cg->GetFormatter(), params_[1]->GetUnsigned(), params_[2]->GetUnsigned());
       cg->GetFormatter(), params_[1]->GetUnsigned(), params_[2]->GetUnsigned());
     code_gen->AddOutputLine(
     code_gen->AddOutputLine(
-      (boost::format("-- %1% = field:get_movie_frame()") % destination).str()
+      (boost::format("%1% = movie_player:get_frame()") % destination).str()
     );
     );
 }
 }

+ 4 - 0
test/CMakeLists.txt

@@ -91,6 +91,8 @@ set(SOURCE_FILES
     core/AudioManager.cpp
     core/AudioManager.cpp
     core/Background2D.cpp
     core/Background2D.cpp
     core/Background2DAnimation.cpp
     core/Background2DAnimation.cpp
+    core/MovieCameraFrame.cpp
+    core/MovieState.cpp
     core/ScreenFadeState.cpp
     core/ScreenFadeState.cpp
     core/CameraManager.cpp
     core/CameraManager.cpp
     core/ConfigCmd.cpp
     core/ConfigCmd.cpp
@@ -283,6 +285,8 @@ add_test(NAME v-gears-tests COMMAND v-gears-tests)
 add_executable(v-gears-installer-tests
 add_executable(v-gears-installer-tests
     installer/InstallLogic.cpp
     installer/InstallLogic.cpp
     installer/ExternalTools.cpp
     installer/ExternalTools.cpp
+    installer/MovieCamera.cpp
+    ${CMAKE_SOURCE_DIR}/src/installer/MovieCamera.cpp
     ${CMAKE_SOURCE_DIR}/src/installer/ExternalTools.cpp
     ${CMAKE_SOURCE_DIR}/src/installer/ExternalTools.cpp
     ${CMAKE_SOURCE_DIR}/src/installer/InstallLayout.cpp
     ${CMAKE_SOURCE_DIR}/src/installer/InstallLayout.cpp
     ${CMAKE_SOURCE_DIR}/src/installer/InstallLog.cpp
     ${CMAKE_SOURCE_DIR}/src/installer/InstallLog.cpp

+ 82 - 0
test/core/MovieCameraFrame.cpp

@@ -0,0 +1,82 @@
+/*
+ * Copyright (C) 2022 The V-Gears Team
+ *
+ * This file is part of V-Gears
+ *
+ * V-Gears is free software: you can redistribute it and/or modify it under
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation, version 3.0 (GPLv3) of the License.
+ *
+ * V-Gears is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ */
+
+#include <boost/test/unit_test.hpp>
+#include <OgreMath.h>
+#include <cstring>
+#include <vector>
+#include "core/MovieCameraFrame.h"
+
+namespace{
+
+    // The last frame of the opening movie. It's the camera of the field md1stin, whose background
+    // the movie ends with: position 28.4846 210.83 3.10751, orientation 0.742188 0.667516
+    // -0.0403783 -0.0450658, and a vertical field of view of 34.5389 degrees.
+    const std::vector<uint8_t> LAST_FRAME_OF_THE_OPENING = {
+      0xe2, 0x0f, 0x11, 0xfe, 0x00, 0x00, 0xcb, 0xff, 0x52, 0xfe,
+      0x16, 0xf0, 0xeb, 0x01, 0xcb, 0x0f, 0x4f, 0xfe, 0x4f, 0xfe,
+      0x93, 0xfe, 0xff, 0xff, 0xcf, 0x0c, 0x00, 0x00, 0x69, 0x96,
+      0xff, 0xff, 0x00, 0x00, 0x00, 0x00, 0x82, 0x01, 0x00, 0x00
+    };
+}
+
+BOOST_AUTO_TEST_CASE(TestMovieCameraFrameWithoutCameraIsNotValid){
+    const uint8_t frame[MovieCameraFrame::SIZE] = {};
+    BOOST_CHECK(!MovieCameraFrame::Parse(frame, 1.0f).valid);
+    // The projection alone isn't a camera.
+    uint8_t projection_only[MovieCameraFrame::SIZE] = {};
+    projection_only[36] = 0x82;
+    projection_only[37] = 0x01;
+    BOOST_CHECK(!MovieCameraFrame::Parse(projection_only, 1.0f).valid);
+}
+
+BOOST_AUTO_TEST_CASE(TestMovieCameraFrameIdentityRotation){
+    // Identity rotation (4096 on the diagonal), no translation, focal length 386.
+    uint8_t data[MovieCameraFrame::SIZE] = {};
+    const int16_t one = 4096;
+    for (int i = 0; i < 3; ++ i) std::memcpy(data + (i * 3 + i) * 2, &one, 2);
+    const uint16_t focal = 386;
+    std::memcpy(data + 36, &focal, 2);
+    const MovieCameraFrame frame = MovieCameraFrame::Parse(data, 1.0f);
+    BOOST_REQUIRE(frame.valid);
+    BOOST_CHECK_SMALL(frame.position.length(), 0.0001f);
+    // The field of view the field md1stin has with this focal length.
+    BOOST_CHECK_CLOSE(frame.fov.valueDegrees(), 34.5389f, 0.01f);
+}
+
+BOOST_AUTO_TEST_CASE(TestMovieCameraFrameTranslationIsScaledByTheField){
+    uint8_t data[MovieCameraFrame::SIZE] = {};
+    const int16_t one = 4096;
+    for (int i = 0; i < 3; ++ i) std::memcpy(data + (i * 3 + i) * 2, &one, 2);
+    const int32_t translation = -128 * 10;
+    std::memcpy(data + 20, &translation, 4);
+    const uint16_t focal = 386;
+    std::memcpy(data + 36, &focal, 2);
+    // The position is the opposite of the translation, divided by 128 and by the field scale.
+    BOOST_CHECK_CLOSE(MovieCameraFrame::Parse(data, 1.0f).position.x, 10.0f, 0.01f);
+    BOOST_CHECK_CLOSE(MovieCameraFrame::Parse(data, 2.0f).position.x, 5.0f, 0.01f);
+}
+
+BOOST_AUTO_TEST_CASE(TestMovieCameraFrameLastFrameOfTheOpeningIsTheCameraOfTheField){
+    const MovieCameraFrame frame = MovieCameraFrame::Parse(LAST_FRAME_OF_THE_OPENING.data(), 1.0f);
+    BOOST_REQUIRE(frame.valid);
+    // The values the installer wrote for md1stin, to the precision of the camera data.
+    BOOST_CHECK_CLOSE(frame.position.x, 28.4846f, 0.1f);
+    BOOST_CHECK_CLOSE(frame.position.y, 210.83f, 0.1f);
+    BOOST_CHECK_CLOSE(frame.position.z, 3.10751f, 3.0f);
+    BOOST_CHECK_CLOSE(frame.fov.valueDegrees(), 34.5389f, 0.01f);
+    const Ogre::Quaternion expected(0.742188f, 0.667516f, -0.0403783f, -0.0450658f);
+    BOOST_CHECK(frame.orientation.equals(expected, Ogre::Radian(0.01f)));
+}

+ 89 - 0
test/core/MovieState.cpp

@@ -0,0 +1,89 @@
+/*
+ * Copyright (C) 2022 The V-Gears Team
+ *
+ * This file is part of V-Gears
+ *
+ * V-Gears is free software: you can redistribute it and/or modify it under
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation, version 3.0 (GPLv3) of the License.
+ *
+ * V-Gears is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ */
+
+#include <boost/test/unit_test.hpp>
+#include "core/MovieState.h"
+
+BOOST_AUTO_TEST_CASE(TestMovieStateReportsAHighFrameIfThereIsNoMovie){
+    MovieState movie;
+    BOOST_CHECK(!movie.IsPlaying());
+    BOOST_CHECK_EQUAL(movie.GetFrame(), MovieState::NO_MOVIE_FRAME);
+    // A movie without frames is a movie that isn't installed.
+    movie.Start(0, 15.0f);
+    BOOST_CHECK(!movie.IsPlaying());
+    BOOST_CHECK_EQUAL(movie.GetFrame(), MovieState::NO_MOVIE_FRAME);
+}
+
+BOOST_AUTO_TEST_CASE(TestMovieStateShowsTheFrameOfTheElapsedTime){
+    MovieState movie;
+    movie.Start(30, 15.0f);
+    BOOST_CHECK(movie.IsPlaying());
+    BOOST_CHECK_EQUAL(movie.GetFrame(), 0);
+    BOOST_CHECK(!movie.Advance(0.5f));
+    BOOST_CHECK_EQUAL(movie.GetFrame(), 7);
+    BOOST_CHECK(!movie.Advance(0.5f));
+    BOOST_CHECK_EQUAL(movie.GetFrame(), 15);
+}
+
+BOOST_AUTO_TEST_CASE(TestMovieStateEndsAfterTheLastFrame){
+    MovieState movie;
+    movie.Start(30, 15.0f);
+    BOOST_CHECK(!movie.Advance(1.9f));
+    BOOST_CHECK(movie.IsPlaying());
+    BOOST_CHECK(movie.Advance(0.2f));
+    BOOST_CHECK(!movie.IsPlaying());
+    // The frame stays at the number of frames, and the end is only reported once.
+    BOOST_CHECK_EQUAL(movie.GetFrame(), 30);
+    BOOST_CHECK(!movie.Advance(5.0f));
+    BOOST_CHECK_EQUAL(movie.GetFrame(), 30);
+}
+
+BOOST_AUTO_TEST_CASE(TestMovieStateStopKeepsTheFrame){
+    MovieState movie;
+    movie.Start(300, 15.0f);
+    movie.Advance(2.0f);
+    movie.Stop();
+    BOOST_CHECK(!movie.IsPlaying());
+    BOOST_CHECK_EQUAL(movie.GetFrame(), 30);
+    BOOST_CHECK(!movie.Advance(1.0f));
+    BOOST_CHECK_EQUAL(movie.GetFrame(), 30);
+}
+
+BOOST_AUTO_TEST_CASE(TestMovieStateStartRestarts){
+    MovieState movie;
+    movie.Start(30, 15.0f);
+    movie.Advance(1.0f);
+    movie.Start(60, 30.0f);
+    BOOST_CHECK(movie.IsPlaying());
+    BOOST_CHECK_EQUAL(movie.GetFrame(), 0);
+    movie.Advance(0.5f);
+    BOOST_CHECK_EQUAL(movie.GetFrame(), 15);
+}
+
+BOOST_AUTO_TEST_CASE(TestMovieStatePreparedMovieIsAtFrameZeroUntilItStarts){
+    MovieState movie;
+    movie.Prepare();
+    BOOST_CHECK(!movie.IsPlaying());
+    BOOST_CHECK_EQUAL(movie.GetFrame(), 0);
+    BOOST_CHECK(!movie.Advance(5.0f));
+    BOOST_CHECK_EQUAL(movie.GetFrame(), 0);
+    movie.Start(30, 15.0f);
+    movie.Advance(1.0f);
+    BOOST_CHECK_EQUAL(movie.GetFrame(), 15);
+    // Choosing another movie afterwards starts again from frame 0.
+    movie.Stop();
+    movie.Prepare();
+    BOOST_CHECK_EQUAL(movie.GetFrame(), 0);
+}

+ 77 - 0
test/installer/MovieCamera.cpp

@@ -0,0 +1,77 @@
+/*
+ * Copyright (C) 2022 The V-Gears Team
+ *
+ * This file is part of V-Gears
+ *
+ * V-Gears is free software: you can redistribute it and/or modify it under
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation, version 3.0 (GPLv3) of the License.
+ *
+ * V-Gears is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ */
+
+#include <boost/test/unit_test.hpp>
+#include <cstring>
+#include "installer/MovieCamera.h"
+
+namespace{
+
+    std::vector<uint8_t> Frames(const int count, const int first_with_camera){
+        std::vector<uint8_t> camera(count * MovieCamera::FRAME_SIZE, 0);
+        for (int frame = first_with_camera; first_with_camera >= 0 && frame < count; ++ frame)
+            camera[frame * MovieCamera::FRAME_SIZE] = 1;
+        // Every frame has the projection, which doesn't count.
+        for (int frame = 0; frame < count; ++ frame)
+            camera[frame * MovieCamera::FRAME_SIZE + 36] = 1;
+        return camera;
+    }
+
+    void Append(std::vector<uint8_t>& data, const void* source, const size_t size){
+        const uint8_t* bytes = static_cast<const uint8_t*>(source);
+        data.insert(data.end(), bytes, bytes + size);
+    }
+
+    // An archive with one file.
+    std::vector<uint8_t> Archive(const std::string& name, const std::vector<uint8_t>& content){
+        std::vector<uint8_t> archive(12, 0);
+        const uint32_t count = 1;
+        Append(archive, &count, 4);
+        char entry_name[20] = {};
+        std::memcpy(entry_name, name.c_str(), name.size());
+        Append(archive, entry_name, 20);
+        const uint32_t offset = 16 + 27;
+        Append(archive, &offset, 4);
+        archive.push_back(0);
+        archive.push_back(0);
+        archive.push_back(0);
+        Append(archive, entry_name, 20);
+        const uint32_t size = static_cast<uint32_t>(content.size());
+        Append(archive, &size, 4);
+        archive.insert(archive.end(), content.begin(), content.end());
+        return archive;
+    }
+}
+
+BOOST_AUTO_TEST_CASE(TestMovieCameraFirstFrameWithACamera){
+    BOOST_CHECK_EQUAL(MovieCamera::FirstCameraFrame(Frames(10, 4)), 4);
+    BOOST_CHECK_EQUAL(MovieCamera::FirstCameraFrame(Frames(10, 0)), 0);
+    // The projection alone is not a camera.
+    BOOST_CHECK_EQUAL(MovieCamera::FirstCameraFrame(Frames(10, -1)), -1);
+    BOOST_CHECK_EQUAL(MovieCamera::FirstCameraFrame({}), -1);
+}
+
+BOOST_AUTO_TEST_CASE(TestMovieCameraReadsAFileOfAnArchive){
+    const std::vector<uint8_t> camera = Frames(5, 3);
+    const std::vector<uint8_t> archive = Archive("opening.cam", camera);
+    std::vector<uint8_t> content;
+    BOOST_REQUIRE(MovieCamera::ReadLgpFile(archive, "opening.cam", content));
+    BOOST_CHECK(content == camera);
+    BOOST_CHECK(!MovieCamera::ReadLgpFile(archive, "other.cam", content));
+    // A truncated archive is not read.
+    std::vector<uint8_t> truncated(archive.begin(), archive.end() - 10);
+    BOOST_CHECK(!MovieCamera::ReadLgpFile(truncated, "opening.cam", content));
+    BOOST_CHECK(!MovieCamera::ReadLgpFile({}, "opening.cam", content));
+}

+ 14 - 0
test/installer/decompiler/field/FieldDecompiler.cpp

@@ -155,6 +155,20 @@ BOOST_AUTO_TEST_CASE(TestFieldDecompilerMovaWalksToTheEntityAndWaits){
     BOOST_CHECK(output.find("UNIMPLMENTED") == std::string::npos);
     BOOST_CHECK(output.find("UNIMPLMENTED") == std::string::npos);
 }
 }
 
 
+BOOST_AUTO_TEST_CASE(TestFieldDecompilerMoviesAreChosenPlayedAndTheirFrameRead){
+    FieldScriptFormatter formatter;
+    // PMVIE 53, MOVIE, MVIEF into the variable 6 of the bank 6.
+    const std::string output = DecompileWith({0xF8, 53, 0xF9, 0xFA, 0x06, 0x06}, formatter);
+    const size_t set = output.find("movie_player:set(\"opening\", ");
+    const size_t play = output.find("movie_player:play()");
+    const size_t frame = output.find("Banks[6][6] = movie_player:get_frame()");
+    BOOST_REQUIRE(set != std::string::npos && play != std::string::npos);
+    BOOST_REQUIRE(frame != std::string::npos);
+    BOOST_CHECK(set < play && play < frame);
+    // An unknown movie is not played.
+    BOOST_CHECK(DecompileWith({0xF8, 200}, formatter).find("movie_player:set") == std::string::npos);
+}
+
 BOOST_AUTO_TEST_CASE(TestFieldDecompilerScreenFades){
 BOOST_AUTO_TEST_CASE(TestFieldDecompilerScreenFades){
     FieldScriptFormatter formatter;
     FieldScriptFormatter formatter;
     // FADE 255, 0, 0, speed 8, type 5 - NFADE type 11, 10, 20, 30, 15 frames - FADEW.
     // FADE 255, 0, 0, speed 8, type 5 - NFADE type 11, 10, 20, 30, 15 frames - FADEW.